@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/src/cli.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/** git-file-manager 命令行入口 */
|
|
5
|
+
|
|
6
|
+
const { parseArgs } = require('util');
|
|
7
|
+
const log = require('./log');
|
|
8
|
+
const git = require('./git');
|
|
9
|
+
const rescue = require('./commands/rescue');
|
|
10
|
+
const sync = require('./commands/sync');
|
|
11
|
+
const undo = require('./commands/undo');
|
|
12
|
+
|
|
13
|
+
const { CliError } = log;
|
|
14
|
+
const VERSION = require('../package.json').version;
|
|
15
|
+
|
|
16
|
+
const HELP = `
|
|
17
|
+
git-file-manager —— Git 提交流程救火工具
|
|
18
|
+
|
|
19
|
+
用法
|
|
20
|
+
git file-manager <命令> [选项]
|
|
21
|
+
git file-manager rescue --from <分支> --to <分支> <commit id...>
|
|
22
|
+
git file-manager sync <commit id...> --to <目录>
|
|
23
|
+
|
|
24
|
+
提交可以直接写成 commit id(可多个、可混区间):
|
|
25
|
+
git file-manager rescue --from develop --to feature a1b2c3d 9f8e7d6
|
|
26
|
+
git file-manager sync a1b2c3d 9f8e7d6 --to ../another-app
|
|
27
|
+
也可以写 --commits "a1b2c3d,9f8e7d6"(PowerShell 下逗号必须加引号,否则会被拆成两个参数)
|
|
28
|
+
|
|
29
|
+
命令
|
|
30
|
+
rescue 把提交错分支的 commit 剪切到功能分支,并把源分支彻底还原(无多余提交记录)
|
|
31
|
+
sync 把指定 commit 的文件改动,按原目录结构同步到另一个目录
|
|
32
|
+
undo 回滚最近一次 rescue;--list 查看历史备份
|
|
33
|
+
|
|
34
|
+
rescue
|
|
35
|
+
--from <分支> 错误提交所在的分支(默认当前分支)
|
|
36
|
+
--to <分支> 目标功能分支(必须是已存在的本地分支)
|
|
37
|
+
--commits <表达式> "A..B" 连续 | "A,B,C" 不连续 | "A..B,C,D..E" 混合(区间默认含两端)
|
|
38
|
+
也可以直接跟命令后面:rescue --from dev --to feat a1b2c3d 9f8e7d6
|
|
39
|
+
--last <N> 改取 --from 上最近 N 个 commit
|
|
40
|
+
--exclusive 区间不含左端 commit(等价 git 的 "A..B" 语义)
|
|
41
|
+
--base <commit> 源分支重建基点(默认取最早已选中 commit 的父提交)
|
|
42
|
+
--no-move 只清理源分支,不把 commit 迁到 --to
|
|
43
|
+
--squash 迁到 --to 时压成一个 commit
|
|
44
|
+
--allow-dirty 允许在脏工作区执行(默认拒绝)
|
|
45
|
+
--allow-merges 允许区间/选中里存在 merge commit(默认拒绝)
|
|
46
|
+
--continue 冲突解决后,接着跑
|
|
47
|
+
--abort 放弃本次 rescue,把两个分支都还原
|
|
48
|
+
-n, --dry-run 只打印计划,不动任何东西
|
|
49
|
+
|
|
50
|
+
sync
|
|
51
|
+
--commits / --last / --exclusive 同 rescue,含义见上
|
|
52
|
+
--from <分支> 解析 --last 的起点分支(默认 HEAD)
|
|
53
|
+
--to <目录> 目标目录(普通目录或另一个工作副本都行)
|
|
54
|
+
--mode <模式> content(默认,按 commit 时点的内容写入)| patch(把 diff apply 过去)
|
|
55
|
+
--paths <a,b,...> 只同步匹配的路径(支持 * ? ** 通配)
|
|
56
|
+
--exclude <a,b,...> 排除路径
|
|
57
|
+
--delete 同步删除源 commit 中删掉的文件(默认只报告不删)
|
|
58
|
+
--backup 覆盖/删除前,把目标旧文件备份到 <to>/.gfm-backup/<时间戳>/
|
|
59
|
+
-n, --dry-run 只打印计划,不写任何文件
|
|
60
|
+
|
|
61
|
+
undo
|
|
62
|
+
--list 列出所有 backup ref 与最近一次操作
|
|
63
|
+
--ref <ref> 把分支回滚到指定的 backup ref
|
|
64
|
+
|
|
65
|
+
通用
|
|
66
|
+
-v, --verbose 打印执行的每条 git 命令
|
|
67
|
+
-h, --help 显示帮助
|
|
68
|
+
--version 显示版本
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
const OPTIONS = {
|
|
72
|
+
from: { type: 'string' },
|
|
73
|
+
to: { type: 'string' },
|
|
74
|
+
commits: { type: 'string' },
|
|
75
|
+
last: { type: 'string' },
|
|
76
|
+
base: { type: 'string' },
|
|
77
|
+
mode: { type: 'string' },
|
|
78
|
+
paths: { type: 'string' },
|
|
79
|
+
exclude: { type: 'string' },
|
|
80
|
+
ref: { type: 'string' },
|
|
81
|
+
list: { type: 'boolean' },
|
|
82
|
+
inclusive: { type: 'boolean' },
|
|
83
|
+
exclusive: { type: 'boolean' },
|
|
84
|
+
'no-move': { type: 'boolean' },
|
|
85
|
+
squash: { type: 'boolean' },
|
|
86
|
+
'allow-dirty': { type: 'boolean' },
|
|
87
|
+
'allow-merges': { type: 'boolean' },
|
|
88
|
+
delete: { type: 'boolean' },
|
|
89
|
+
backup: { type: 'boolean' },
|
|
90
|
+
'dry-run': { type: 'boolean', short: 'n' },
|
|
91
|
+
continue: { type: 'boolean' },
|
|
92
|
+
abort: { type: 'boolean' },
|
|
93
|
+
verbose: { type: 'boolean', short: 'v' },
|
|
94
|
+
help: { type: 'boolean', short: 'h' },
|
|
95
|
+
version: { type: 'boolean' },
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
function toOpts(values) {
|
|
99
|
+
return {
|
|
100
|
+
from: values.from,
|
|
101
|
+
to: values.to,
|
|
102
|
+
commits: values.commits,
|
|
103
|
+
last: values.last == null ? undefined : Number(values.last),
|
|
104
|
+
base: values.base,
|
|
105
|
+
mode: values.mode,
|
|
106
|
+
paths: values.paths,
|
|
107
|
+
exclude: values.exclude,
|
|
108
|
+
ref: values.ref,
|
|
109
|
+
list: Boolean(values.list),
|
|
110
|
+
inclusive: !values.exclusive,
|
|
111
|
+
move: !values['no-move'],
|
|
112
|
+
squash: Boolean(values.squash),
|
|
113
|
+
allowDirty: Boolean(values['allow-dirty']),
|
|
114
|
+
allowMerges: Boolean(values['allow-merges']),
|
|
115
|
+
del: Boolean(values.delete),
|
|
116
|
+
backup: Boolean(values.backup),
|
|
117
|
+
dryRun: Boolean(values['dry-run']),
|
|
118
|
+
continue: Boolean(values.continue),
|
|
119
|
+
abort: Boolean(values.abort),
|
|
120
|
+
verbose: Boolean(values.verbose),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 把 --commits 与直接跟在命令后面的 commit id 合并成一个表达式 */
|
|
125
|
+
function mergeCommitArgs(optionValue, positionalArgs) {
|
|
126
|
+
const parts = [];
|
|
127
|
+
const push = (value) => {
|
|
128
|
+
if (value == null) return;
|
|
129
|
+
for (const item of (Array.isArray(value) ? value : [value])) {
|
|
130
|
+
const text = String(item).trim();
|
|
131
|
+
if (text) parts.push(text);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
push(optionValue);
|
|
135
|
+
(positionalArgs || []).forEach(push);
|
|
136
|
+
return parts.length ? parts.join(',') : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function main(argv) {
|
|
140
|
+
const { values, positionals } = parseArgs({
|
|
141
|
+
args: argv,
|
|
142
|
+
options: OPTIONS,
|
|
143
|
+
allowPositionals: true,
|
|
144
|
+
strict: false,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const command = positionals[0];
|
|
148
|
+
const opts = toOpts(values);
|
|
149
|
+
// 没有用 --commits 时,命令后面直接写的 commit id 也算数(两者可混用)
|
|
150
|
+
opts.commits = mergeCommitArgs(values.commits, positionals.slice(1));
|
|
151
|
+
log.setVerbose(opts.verbose);
|
|
152
|
+
|
|
153
|
+
if (values.version || command === 'version') {
|
|
154
|
+
log.plain(VERSION);
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
if (!command || values.help || command === 'help') {
|
|
158
|
+
log.plain(HELP.trim());
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (command === 'rescue') return rescue.run(opts);
|
|
163
|
+
if (command === 'sync') return sync.run(opts);
|
|
164
|
+
if (command === 'undo') return undo.run(opts);
|
|
165
|
+
throw new CliError(`未知命令:${command}`, '见 git file-manager --help');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (require.main === module) {
|
|
169
|
+
try {
|
|
170
|
+
const code = main(process.argv.slice(2));
|
|
171
|
+
process.exit(typeof code === 'number' ? code : 0);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (err instanceof CliError) {
|
|
174
|
+
log.bad(err.message);
|
|
175
|
+
if (err.hint) log.info(`提示:${err.hint}`);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
log.bad(`未预期的错误:\n${err && err.stack ? err.stack : String(err)}`);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { main };
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* rescue —— 把提交错分支的 commit 剪切到功能分支,并把源分支彻底还原。
|
|
5
|
+
*
|
|
6
|
+
* 做法(先保住内容,再改历史):
|
|
7
|
+
* ① 把选中的 commit 按旧 -> 新顺序 cherry-pick 到 --to 分支;
|
|
8
|
+
* ② 源分支上「回到 base,然后重放没被选中的 commit」,选中的 commit 从历史里消失
|
|
9
|
+
* (不是 revert,git log 里干干净净,文件改动也随之回退)。
|
|
10
|
+
*
|
|
11
|
+
* 与手工 `git rebase --onto C^ C br` 的区别:不连续的多个 commit、选中的 commit 相邻、
|
|
12
|
+
* 选中与保留的改动相互重叠,这些情况下 rebase --onto 需要反复手工分段,这里一次做完。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const git = require('../git');
|
|
16
|
+
const log = require('../log');
|
|
17
|
+
const backup = require('../backup');
|
|
18
|
+
const { parseCommitSpec, describe, short, summarize } = require('../commitSpec');
|
|
19
|
+
|
|
20
|
+
const { CliError } = log;
|
|
21
|
+
|
|
22
|
+
const PHASES = {
|
|
23
|
+
MOVE: 'move',
|
|
24
|
+
MOVE_CONFLICT: 'move-conflict',
|
|
25
|
+
REWRITE: 'rewrite',
|
|
26
|
+
REWRITE_CONFLICT: 'rewrite-conflict',
|
|
27
|
+
DONE: 'done',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function buildPlan(opts) {
|
|
31
|
+
const from = opts.from || git.currentBranch();
|
|
32
|
+
if (!from) {
|
|
33
|
+
throw new CliError('当前处于 detached HEAD,无法判断源分支', '请用 --from <分支> 显式指定错误提交所在的分支');
|
|
34
|
+
}
|
|
35
|
+
if (!opts.to) {
|
|
36
|
+
throw new CliError('缺少 --to 参数', '用法:git file-manager rescue --from <错的分支> --to <功能分支> --commits "A..B"');
|
|
37
|
+
}
|
|
38
|
+
const to = opts.to;
|
|
39
|
+
if (from === to) throw new CliError(`源分支和目标分支不能相同:${from}`);
|
|
40
|
+
|
|
41
|
+
for (const [flag, name] of [['--from', from], ['--to', to]]) {
|
|
42
|
+
if (!git.refExists(`refs/heads/${name}`)) {
|
|
43
|
+
throw new CliError(`${flag} 不是本地分支:${name}`, '请先 git switch 创建该分支,或检查分支名拼写');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const fromTip = git.mustRevParse(from, `--from ${from}`);
|
|
48
|
+
const toTip = git.mustRevParse(to, `--to ${to}`);
|
|
49
|
+
|
|
50
|
+
const { shas: commits } = parseCommitSpec(opts.commits, {
|
|
51
|
+
last: opts.last,
|
|
52
|
+
inclusive: opts.inclusive,
|
|
53
|
+
fromTip,
|
|
54
|
+
fromLabel: from,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const merges = commits.filter((s) => git.commitInfo(s).parents.length > 1);
|
|
58
|
+
if (merges.length && !opts.allowMerges) {
|
|
59
|
+
throw new CliError(
|
|
60
|
+
`选中的 commit 里有 merge commit:\n ${merges.map(short).join('\n ')}`,
|
|
61
|
+
'merge commit 无法无损剪切,请改用 --allow-merges(风险自负)或手工处理',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let base;
|
|
66
|
+
if (opts.base) {
|
|
67
|
+
base = git.mustRevParse(opts.base, `--base ${opts.base}`);
|
|
68
|
+
} else {
|
|
69
|
+
const first = commits[0];
|
|
70
|
+
if (git.commitInfo(first).parents.length === 0) {
|
|
71
|
+
throw new CliError(`${short(first)} 是根提交,没有父提交可用作基点`, '请用 --base <commit> 显式指定基点');
|
|
72
|
+
}
|
|
73
|
+
base = git.mustRevParse(`${first}^`);
|
|
74
|
+
}
|
|
75
|
+
if (commits.includes(base)) throw new CliError(`基点 ${short(base)} 本身在被选中的 commit 里`);
|
|
76
|
+
|
|
77
|
+
const rangeCommits = git.lines(['rev-list', '--reverse', `${base}..${fromTip}`]);
|
|
78
|
+
if (rangeCommits.length === 0) {
|
|
79
|
+
throw new CliError(`基点 ${short(base)} 到 ${from} 之间没有任何 commit`, '请检查 --base 是否给得太新');
|
|
80
|
+
}
|
|
81
|
+
for (const c of commits) {
|
|
82
|
+
if (!rangeCommits.includes(c)) {
|
|
83
|
+
throw new CliError(
|
|
84
|
+
`commit ${short(c)} 不在 ${short(base)}..${from} 区间内,无法从源分支移除`,
|
|
85
|
+
'请用 --base 指定更早的基点',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!git.isAncestor(base, fromTip)) {
|
|
90
|
+
throw new CliError(`基点 ${short(base)} 不是 ${from} 的祖先`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const rangeMerges = git.lines(['rev-list', '--merges', `${base}..${fromTip}`]);
|
|
94
|
+
if (rangeMerges.length && !opts.allowMerges) {
|
|
95
|
+
throw new CliError(
|
|
96
|
+
`基点之后的区间里含 merge commit,重写会丢失合并信息:\n ${rangeMerges.map(short).join('\n ')}`,
|
|
97
|
+
'请用 --base 把基点挪到 merge 之后,或加 --allow-merges(风险自负)',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const picked = new Set(commits);
|
|
102
|
+
const keep = rangeCommits.filter((s) => !picked.has(s));
|
|
103
|
+
const already = commits.filter((s) => git.isAncestor(s, toTip));
|
|
104
|
+
|
|
105
|
+
return { from, to, fromTip, toTip, commits, base, keep, rangeCommits, already };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function printPlan(plan, opts) {
|
|
109
|
+
const { from, to, commits, keep, base } = plan;
|
|
110
|
+
const mode = opts.move === false ? '只清理源分支(--no-move)' : (opts.squash ? '迁移并压成一个 commit(--squash)' : '迁移并保留原 commit');
|
|
111
|
+
|
|
112
|
+
log.step('rescue 计划');
|
|
113
|
+
log.info(`源分支 : ${from} (${short(plan.fromTip)})`);
|
|
114
|
+
log.info(`目标分支 : ${to} (${short(plan.toTip)})`);
|
|
115
|
+
log.info(`基点 : ${short(base)}`);
|
|
116
|
+
log.info(`方式 : ${mode}`);
|
|
117
|
+
|
|
118
|
+
if (opts.move !== false) {
|
|
119
|
+
log.plain(`\n 迁移到 ${to} 的 commit(旧 -> 新,共 ${commits.length} 个)`);
|
|
120
|
+
log.plain(commits.map((s) => ` + ${describe(s)}`).join('\n'));
|
|
121
|
+
}
|
|
122
|
+
log.plain(`\n 将从 ${from} 的历史中移除(共 ${commits.length} 个)`);
|
|
123
|
+
log.plain(commits.map((s) => ` - ${describe(s)}`).join('\n'));
|
|
124
|
+
log.plain(`\n 保留并重放(共 ${keep.length} 个)`);
|
|
125
|
+
log.plain(keep.length ? keep.map((s) => ` = ${describe(s)}`).join('\n') : ' (无)');
|
|
126
|
+
|
|
127
|
+
if (plan.already.length) {
|
|
128
|
+
log.warn(`以下 commit 的内容已在 ${to} 上,cherry-pick 会变成空提交并自动跳过:`);
|
|
129
|
+
log.plain(plan.already.map((s) => ` ${short(s)}`).join('\n'));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const remote = git.remoteBranchesContaining(plan.fromTip);
|
|
133
|
+
if (remote.length) {
|
|
134
|
+
log.warn(`${from} 已经推送到远端(${remote.join(', ')}),本次历史重写后必须 force-push:`);
|
|
135
|
+
log.plain(` git push --force-with-lease origin ${from}`);
|
|
136
|
+
log.plain(' 协作方需要 git fetch && git reset --hard origin/' + from);
|
|
137
|
+
}
|
|
138
|
+
log.plain('');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------- 执行
|
|
142
|
+
|
|
143
|
+
function guardRepo(opts) {
|
|
144
|
+
const root = git.findRoot();
|
|
145
|
+
git.setCwd(root);
|
|
146
|
+
if (!opts.continue && !opts.abort && !opts.dryRun) {
|
|
147
|
+
const active = git.inProgress();
|
|
148
|
+
if (active.length) {
|
|
149
|
+
throw new CliError(
|
|
150
|
+
`仓库里有进行中的操作:${active.join(' / ')}`,
|
|
151
|
+
'先把它收尾(或 git cherry-pick --abort)再执行 rescue',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (!git.isClean() && !opts.allowDirty) {
|
|
155
|
+
throw new CliError('工作区不干净,无法安全剪切历史', '先 git commit / git stash;确实要带着脏工作区跑就加 --allow-dirty');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return root;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 按顺序 cherry-pick;冲突时把进度写进状态文件并返回 */
|
|
162
|
+
function replay(list, startIndex, state, phase) {
|
|
163
|
+
for (let i = startIndex; i < list.length; i++) {
|
|
164
|
+
const sha = list[i];
|
|
165
|
+
const r = git.cherryPick(sha);
|
|
166
|
+
if (r.conflict) {
|
|
167
|
+
state.phase = `${phase}-conflict`;
|
|
168
|
+
state.cursor = i;
|
|
169
|
+
backup.saveState(state);
|
|
170
|
+
return { conflict: true, index: i, sha };
|
|
171
|
+
}
|
|
172
|
+
if (r.empty) {
|
|
173
|
+
log.warn(`${short(sha)} 的内容已存在,跳过(不产生空提交)`);
|
|
174
|
+
} else {
|
|
175
|
+
const now = git.revParse('HEAD');
|
|
176
|
+
log.ok(`${describe(sha)} -> ${short(now)}`);
|
|
177
|
+
if (!state.moved) state.moved = [];
|
|
178
|
+
state.moved.push(now);
|
|
179
|
+
}
|
|
180
|
+
state.cursor = i + 1;
|
|
181
|
+
state.phase = phase;
|
|
182
|
+
backup.saveState(state);
|
|
183
|
+
}
|
|
184
|
+
return { conflict: false };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function conflictFail(stage, sha) {
|
|
188
|
+
log.plain('');
|
|
189
|
+
log.bad(`cherry-pick 冲突,流程已暂停在 ${short(sha)}(${stage === PHASES.MOVE ? '迁移到目标分支' : '重写源分支'}阶段)`);
|
|
190
|
+
log.info('解决冲突后继续:');
|
|
191
|
+
log.plain(' git add <已解决的文件>');
|
|
192
|
+
log.plain(' git file-manager rescue --continue');
|
|
193
|
+
log.info('放弃本次 rescue 并回滚两个分支:');
|
|
194
|
+
log.plain(' git file-manager rescue --abort');
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function squashMove(plan, state) {
|
|
199
|
+
log.step(`1) 迁移 commit 到 ${plan.to}(--squash)`);
|
|
200
|
+
git.checkout(plan.to);
|
|
201
|
+
for (const sha of plan.commits) {
|
|
202
|
+
const res = git.raw(['cherry-pick', '--no-commit', '--no-edit', sha]);
|
|
203
|
+
if (res.status === 0) continue;
|
|
204
|
+
if (git.hasConflicts()) {
|
|
205
|
+
throw new CliError(
|
|
206
|
+
`--squash 迁移在 ${short(sha)} 上发生冲突`,
|
|
207
|
+
'squash 模式不支持自动续跑:请手工解决冲突并 git commit,或改用非 squash 模式重来',
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
const err = res.stderr.toString('utf8');
|
|
211
|
+
if (/empty|nothing to commit/i.test(err)) {
|
|
212
|
+
git.raw(['cherry-pick', '--skip']);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
throw new CliError(`git cherry-pick --no-commit ${short(sha)} 失败:\n${err.trim()}`);
|
|
216
|
+
}
|
|
217
|
+
const last = plan.commits[plan.commits.length - 1];
|
|
218
|
+
git.run(['commit', '--no-edit', '-C', last]);
|
|
219
|
+
const now = git.revParse('HEAD');
|
|
220
|
+
log.ok(`${plan.commits.length} 个 commit 已压成 ${short(now)}`);
|
|
221
|
+
state.moved = [now];
|
|
222
|
+
state.cursor = plan.commits.length;
|
|
223
|
+
backup.saveState(state);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function doMove(plan, state, opts) {
|
|
227
|
+
if (opts.move === false) {
|
|
228
|
+
log.step('1) 跳过迁移(--no-move)');
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
if (opts.squash) {
|
|
232
|
+
squashMove(plan, state);
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
log.step(`1) 迁移 commit 到 ${plan.to}`);
|
|
236
|
+
git.checkout(plan.to);
|
|
237
|
+
const r = replay(plan.commits, 0, state, PHASES.MOVE);
|
|
238
|
+
if (r.conflict) return conflictFail(PHASES.MOVE, r.sha);
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function finishRewrite(plan, state, opts) {
|
|
243
|
+
const newTip = git.revParse('HEAD');
|
|
244
|
+
git.setBranch(plan.from, newTip);
|
|
245
|
+
git.checkout(plan.from);
|
|
246
|
+
state.phase = PHASES.DONE;
|
|
247
|
+
state.newTip = newTip;
|
|
248
|
+
state.finishedAt = new Date().toISOString();
|
|
249
|
+
backup.saveState(state);
|
|
250
|
+
|
|
251
|
+
log.step('完成');
|
|
252
|
+
log.ok(`${plan.from}: ${short(plan.fromTip)} -> ${short(newTip)}(历史已重写,选中的 commit 已从历史中消失)`);
|
|
253
|
+
if (opts.move !== false) log.ok(`${plan.to}: ${short(plan.toTip)} -> ${short(git.revParse(plan.to))}`);
|
|
254
|
+
log.plain('');
|
|
255
|
+
log.info('后续动作:');
|
|
256
|
+
log.plain(` git switch ${plan.to} # 检查迁移过去的提交`);
|
|
257
|
+
const remote = git.remoteBranchesContaining(plan.fromTip);
|
|
258
|
+
if (remote.length) log.plain(` git push --force-with-lease origin ${plan.from}`);
|
|
259
|
+
log.plain(` git push origin ${plan.to}`);
|
|
260
|
+
log.plain('');
|
|
261
|
+
log.info('撤销本次操作:git file-manager undo');
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function rewriteSource(plan, state, opts, startIndex = 0) {
|
|
266
|
+
log.step(`2) 重写源分支 ${plan.from}`);
|
|
267
|
+
state.phase = PHASES.REWRITE;
|
|
268
|
+
state.cursor = startIndex;
|
|
269
|
+
backup.saveState(state);
|
|
270
|
+
git.checkout(plan.base, { detach: true });
|
|
271
|
+
const r = replay(plan.keep, startIndex, state, PHASES.REWRITE);
|
|
272
|
+
if (r.conflict) return conflictFail(PHASES.REWRITE, r.sha);
|
|
273
|
+
return finishRewrite(plan, state, opts);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function execute(plan, opts) {
|
|
277
|
+
const id = backup.stamp();
|
|
278
|
+
const state = {
|
|
279
|
+
command: 'rescue',
|
|
280
|
+
id,
|
|
281
|
+
startedAt: new Date().toISOString(),
|
|
282
|
+
phase: PHASES.MOVE,
|
|
283
|
+
cursor: 0,
|
|
284
|
+
from: { name: plan.from, tip: plan.fromTip, backup: backup.createRef(plan.from, plan.fromTip, id) },
|
|
285
|
+
to: { name: plan.to, tip: plan.toTip, backup: backup.createRef(plan.to, plan.toTip, id) },
|
|
286
|
+
commits: plan.commits,
|
|
287
|
+
keep: plan.keep,
|
|
288
|
+
base: plan.base,
|
|
289
|
+
moved: [],
|
|
290
|
+
};
|
|
291
|
+
backup.saveState(state);
|
|
292
|
+
log.info(`安全网(backup ref):${state.from.backup}`);
|
|
293
|
+
|
|
294
|
+
const step1 = doMove(plan, state, opts);
|
|
295
|
+
if (step1 !== 0) return step1;
|
|
296
|
+
return rewriteSource(plan, state, opts, 0);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function planFromState(state) {
|
|
300
|
+
return {
|
|
301
|
+
from: state.from.name,
|
|
302
|
+
fromTip: state.from.tip,
|
|
303
|
+
to: state.to.name,
|
|
304
|
+
toTip: state.to.tip,
|
|
305
|
+
commits: state.commits,
|
|
306
|
+
keep: state.keep,
|
|
307
|
+
base: state.base,
|
|
308
|
+
already: [],
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function continueRescue(opts) {
|
|
313
|
+
const state = backup.readState();
|
|
314
|
+
if (!state || state.command !== 'rescue') throw new CliError('没有可以续跑的 rescue 现场');
|
|
315
|
+
if (state.phase === PHASES.DONE) {
|
|
316
|
+
log.ok('上一次 rescue 已经完成,无需续跑');
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const plan = planFromState(state);
|
|
321
|
+
const active = git.inProgress();
|
|
322
|
+
let cursor = state.cursor || 0;
|
|
323
|
+
const inRewrite = state.phase === PHASES.REWRITE || state.phase === PHASES.REWRITE_CONFLICT;
|
|
324
|
+
const list = inRewrite ? state.keep : state.commits;
|
|
325
|
+
|
|
326
|
+
if (active.includes('cherry-pick')) {
|
|
327
|
+
if (git.hasConflicts()) {
|
|
328
|
+
throw new CliError('仍有未解决的冲突', '先把冲突文件 git add 进来,再执行 rescue --continue');
|
|
329
|
+
}
|
|
330
|
+
const r = git.cherryPickContinue();
|
|
331
|
+
if (r.conflict) return conflictFail(inRewrite ? PHASES.REWRITE : PHASES.MOVE, list[cursor] || '');
|
|
332
|
+
log.ok(`冲突已解决并提交:${short(list[cursor])}`);
|
|
333
|
+
cursor += 1;
|
|
334
|
+
state.cursor = cursor;
|
|
335
|
+
backup.saveState(state);
|
|
336
|
+
} else if (state.phase.endsWith('-conflict')) {
|
|
337
|
+
log.warn('没有检测到进行中的 cherry-pick(可能你已经手工提交),按状态文件继续');
|
|
338
|
+
cursor += 1;
|
|
339
|
+
state.cursor = cursor;
|
|
340
|
+
backup.saveState(state);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (inRewrite) {
|
|
344
|
+
const r = replay(plan.keep, cursor, state, PHASES.REWRITE);
|
|
345
|
+
if (r.conflict) return conflictFail(PHASES.REWRITE, r.sha);
|
|
346
|
+
return finishRewrite(plan, state, opts);
|
|
347
|
+
}
|
|
348
|
+
const r = replay(plan.commits, cursor, state, PHASES.MOVE);
|
|
349
|
+
if (r.conflict) return conflictFail(PHASES.MOVE, r.sha);
|
|
350
|
+
return rewriteSource(plan, state, opts, 0);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** 把分支指针还原到 rescue 开始前(backup ref 记录的 sha) */
|
|
354
|
+
function restoreFromState(state) {
|
|
355
|
+
const cur = git.currentBranch();
|
|
356
|
+
if (cur === state.from.name || cur === state.to.name) {
|
|
357
|
+
git.checkout(state.from.tip, { detach: true });
|
|
358
|
+
}
|
|
359
|
+
git.setBranch(state.from.name, state.from.tip);
|
|
360
|
+
git.setBranch(state.to.name, state.to.tip);
|
|
361
|
+
git.checkout(state.from.name);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function abortRescue() {
|
|
365
|
+
const state = backup.readState();
|
|
366
|
+
if (!state || state.command !== 'rescue') {
|
|
367
|
+
throw new CliError('没有可以回滚的 rescue 现场', '用 git file-manager undo --list 查看历史备份');
|
|
368
|
+
}
|
|
369
|
+
if (git.inProgress().includes('cherry-pick')) git.cherryPickAbort();
|
|
370
|
+
if (!git.isClean()) {
|
|
371
|
+
throw new CliError('工作区不干净,无法回滚', '先处理掉未提交的改动(git checkout -- . 或 git stash)再执行 --abort');
|
|
372
|
+
}
|
|
373
|
+
restoreFromState(state);
|
|
374
|
+
backup.clearState();
|
|
375
|
+
log.step('已回滚');
|
|
376
|
+
log.ok(`${state.from.name} -> ${short(state.from.tip)}`);
|
|
377
|
+
log.ok(`${state.to.name} -> ${short(state.to.tip)}`);
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function run(opts) {
|
|
382
|
+
guardRepo(opts);
|
|
383
|
+
if (opts.abort) return abortRescue(opts);
|
|
384
|
+
if (opts.continue) return continueRescue(opts);
|
|
385
|
+
|
|
386
|
+
const pending = backup.readState();
|
|
387
|
+
if (pending && pending.command === 'rescue' && pending.phase !== PHASES.DONE) {
|
|
388
|
+
throw new CliError(
|
|
389
|
+
`上一次 rescue 停在 ${pending.phase},还没有收尾`,
|
|
390
|
+
'先执行 git file-manager rescue --continue 或 --abort',
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const plan = buildPlan(opts);
|
|
395
|
+
printPlan(plan, opts);
|
|
396
|
+
if (opts.dryRun) {
|
|
397
|
+
log.ok('--dry-run:没有改动任何东西');
|
|
398
|
+
return 0;
|
|
399
|
+
}
|
|
400
|
+
return execute(plan, opts);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
module.exports = { run, continueRescue, abortRescue, buildPlan, restoreFromState, PHASES };
|