@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
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* sync —— 把指定 commit(可连续可不连续)里的文件改动,按原目录结构同步到另一个目录。
|
|
5
|
+
*
|
|
6
|
+
* 两种模式:
|
|
7
|
+
* content(默认):取文件在该 commit 时点的完整内容,覆盖写到目标目录的同名相对路径。
|
|
8
|
+
* 不要求目标目录是 git 仓库、也不要求两边基线一致;内容相同自动跳过。
|
|
9
|
+
* patch :生成这些 commit 的 diff 并 git apply 到目标目录。
|
|
10
|
+
* 适合「目标目录是与源同基线的另一个副本,且要保留它上面的本地改动」。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const git = require('../git');
|
|
16
|
+
const log = require('../log');
|
|
17
|
+
const backup = require('../backup');
|
|
18
|
+
const { parseCommitSpec, short } = require('../commitSpec');
|
|
19
|
+
|
|
20
|
+
const { CliError } = log;
|
|
21
|
+
|
|
22
|
+
const MODES = ['content', 'patch'];
|
|
23
|
+
|
|
24
|
+
/** 简易 glob:** 跨目录,* 不跨目录,? 单字符;不含通配符时按路径前缀匹配 */
|
|
25
|
+
function globToRegExp(pattern) {
|
|
26
|
+
const p = String(pattern).replace(/\\/g, '/');
|
|
27
|
+
let re = '';
|
|
28
|
+
for (let i = 0; i < p.length; i++) {
|
|
29
|
+
const ch = p[i];
|
|
30
|
+
if (ch === '*') {
|
|
31
|
+
if (p[i + 1] === '*') {
|
|
32
|
+
i += 1;
|
|
33
|
+
if (p[i + 1] === '/') { i += 1; re += '(?:.*/)?'; } else { re += '.*'; }
|
|
34
|
+
} else {
|
|
35
|
+
re += '[^/]*';
|
|
36
|
+
}
|
|
37
|
+
} else if (ch === '?') {
|
|
38
|
+
re += '[^/]';
|
|
39
|
+
} else if ('\\^$.|+()[]{}'.includes(ch)) {
|
|
40
|
+
re += `\\${ch}`;
|
|
41
|
+
} else {
|
|
42
|
+
re += ch;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return new RegExp(`^${re}$`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeMatcher(patterns) {
|
|
49
|
+
const list = (patterns || []).map((raw) => String(raw).trim().replace(/\\/g, '/')).filter(Boolean);
|
|
50
|
+
if (list.length === 0) return () => false;
|
|
51
|
+
return (file) => list.some((p) => {
|
|
52
|
+
if (!/[*?]/.test(p)) return file === p || file.startsWith(`${p}/`);
|
|
53
|
+
return globToRegExp(p).test(file);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 解析 --paths / --exclude 等逗号分隔(或数组)参数 */
|
|
58
|
+
function toList(value) {
|
|
59
|
+
if (value == null) return [];
|
|
60
|
+
const arr = Array.isArray(value) ? value : String(value).split(',');
|
|
61
|
+
return arr.map((s) => s.trim()).filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 汇总这些 commit 涉及的文件,后出现的改动覆盖先前的。
|
|
66
|
+
* 返回 [{ action, path, fromPath, sha }],action ∈ add | modify | delete | rename
|
|
67
|
+
*/
|
|
68
|
+
function collectChanges(shas, filters) {
|
|
69
|
+
const map = new Map();
|
|
70
|
+
const merges = [];
|
|
71
|
+
|
|
72
|
+
for (const sha of shas) {
|
|
73
|
+
const info = git.commitInfo(sha);
|
|
74
|
+
if (info.parents.length > 1) merges.push(sha);
|
|
75
|
+
const args = ['show', '--name-status', '--format=', '-z', '--find-renames'];
|
|
76
|
+
if (info.parents.length > 1) args.push('--first-parent');
|
|
77
|
+
args.push(sha);
|
|
78
|
+
const parts = git.buffer(args).toString('utf8').split('\0');
|
|
79
|
+
let i = 0;
|
|
80
|
+
while (i < parts.length) {
|
|
81
|
+
const status = parts[i++];
|
|
82
|
+
if (!status) continue;
|
|
83
|
+
const code = status[0];
|
|
84
|
+
if (code === 'R' || code === 'C') {
|
|
85
|
+
const fromPath = parts[i++];
|
|
86
|
+
const toPath = parts[i++];
|
|
87
|
+
if (toPath) map.set(toPath, { action: 'rename', path: toPath, fromPath, sha });
|
|
88
|
+
if (fromPath) map.set(fromPath, { action: 'delete', path: fromPath, sha, renamedTo: toPath });
|
|
89
|
+
} else {
|
|
90
|
+
const file = parts[i++];
|
|
91
|
+
if (!file) continue;
|
|
92
|
+
const action = code === 'D' ? 'delete' : (code === 'A' ? 'add' : 'modify');
|
|
93
|
+
map.set(file, { action, path: file, sha });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const entries = Array.from(map.values()).filter((e) => filters(e.path));
|
|
99
|
+
entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
100
|
+
return { entries, merges };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 拼接目标路径并确保没有越界写出 --to 之外 */
|
|
104
|
+
function safeJoin(root, rel) {
|
|
105
|
+
const base = path.resolve(root);
|
|
106
|
+
const full = path.resolve(base, rel);
|
|
107
|
+
const inside = path.relative(base, full);
|
|
108
|
+
if (inside.startsWith('..') || path.isAbsolute(inside)) {
|
|
109
|
+
throw new CliError(`目标路径越界(拒绝写入 --to 之外):${rel}`);
|
|
110
|
+
}
|
|
111
|
+
return full;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const KB = (n) => `${(n / 1024).toFixed(1)} KB`;
|
|
115
|
+
|
|
116
|
+
function printPlan(plan, ctx) {
|
|
117
|
+
const rows = plan.map((item) => [
|
|
118
|
+
{ add: 'A 新增', modify: 'M 修改', delete: 'D 删除', rename: 'R 改名' }[item.action],
|
|
119
|
+
item.path,
|
|
120
|
+
item.size != null ? KB(item.size) : '-',
|
|
121
|
+
item.reason,
|
|
122
|
+
]);
|
|
123
|
+
log.plain('');
|
|
124
|
+
log.plain(log.renderTable(['动作', '文件', '大小', '说明'], rows));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function planEntries(entries, ctx) {
|
|
128
|
+
const plan = [];
|
|
129
|
+
for (const e of entries) {
|
|
130
|
+
const target = safeJoin(ctx.toDir, e.path);
|
|
131
|
+
const exists = fs.existsSync(target);
|
|
132
|
+
if (e.action === 'delete') {
|
|
133
|
+
const reason = e.renamedTo
|
|
134
|
+
? `已改名为 ${e.renamedTo}`
|
|
135
|
+
: (ctx.del ? '删除目标文件' : '跳过(未加 --delete)');
|
|
136
|
+
plan.push(Object.assign({}, e, { target, exists, same: false, reason }));
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const content = git.buffer(['show', `${e.sha}:${e.path}`]);
|
|
140
|
+
const same = exists && fs.readFileSync(target).equals(content);
|
|
141
|
+
let reason;
|
|
142
|
+
if (same) reason = '内容一致,跳过';
|
|
143
|
+
else if (!exists) reason = e.action === 'modify' ? '目标不存在,按新增写入' : '新建';
|
|
144
|
+
else reason = e.action === 'rename' ? '按新路径写入' : '覆盖';
|
|
145
|
+
plan.push(Object.assign({}, e, { target, content, exists, same, size: content.length, reason }));
|
|
146
|
+
}
|
|
147
|
+
return plan;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function writeAtomic(file, buf) {
|
|
151
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
152
|
+
const tmp = `${file}.gfm-tmp-${process.pid}`;
|
|
153
|
+
fs.writeFileSync(tmp, buf);
|
|
154
|
+
fs.renameSync(tmp, file);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function backupFile(item, ctx) {
|
|
158
|
+
if (!ctx.backup) return;
|
|
159
|
+
const dest = safeJoin(ctx.toDir, path.join('.gfm-backup', ctx.stamp, item.path));
|
|
160
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
161
|
+
fs.copyFileSync(item.target, dest);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function applyPlan(plan, ctx) {
|
|
165
|
+
const stat = { written: 0, deleted: 0, skipped: 0, failed: 0 };
|
|
166
|
+
for (const item of plan) {
|
|
167
|
+
if (item.action === 'delete') {
|
|
168
|
+
if (!ctx.del) { stat.skipped += 1; log.warn(`跳过删除 ${item.path}(需要 --delete)`); continue; }
|
|
169
|
+
if (!item.exists) { stat.skipped += 1; continue; }
|
|
170
|
+
if (ctx.dryRun) { stat.deleted += 1; log.info(`[dry-run] 删除 ${item.path}`); continue; }
|
|
171
|
+
backupFile(item, ctx);
|
|
172
|
+
fs.rmSync(item.target, { force: true });
|
|
173
|
+
stat.deleted += 1;
|
|
174
|
+
log.ok(`删除 ${item.path}`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (item.same) { stat.skipped += 1; log.debug(`内容一致,跳过 ${item.path}`); continue; }
|
|
178
|
+
if (ctx.dryRun) { stat.written += 1; log.info(`[dry-run] ${item.exists ? '覆盖' : '新建'} ${item.path} (${KB(item.size)})`); continue; }
|
|
179
|
+
if (item.exists) backupFile(item, ctx);
|
|
180
|
+
writeAtomic(item.target, item.content);
|
|
181
|
+
stat.written += 1;
|
|
182
|
+
log.ok(`${item.exists ? '覆盖' : '新建'} ${item.path} (${KB(item.size)})`);
|
|
183
|
+
}
|
|
184
|
+
return stat;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
188
|
+
|
|
189
|
+
function applyPatchMode(shas, ctx, rawPaths) {
|
|
190
|
+
const stat = { applied: 0, failed: 0 };
|
|
191
|
+
for (const sha of shas) {
|
|
192
|
+
const info = git.commitInfo(sha);
|
|
193
|
+
const args = ['diff', '--binary', '--no-color', '--no-ext-diff'];
|
|
194
|
+
if (info.parents.length > 1) args.push('--first-parent');
|
|
195
|
+
args.push(info.parents[0] || EMPTY_TREE, sha);
|
|
196
|
+
if (rawPaths.length) args.push('--', ...rawPaths);
|
|
197
|
+
const patch = git.buffer(args);
|
|
198
|
+
if (patch.length === 0) { log.debug(`${short(sha)} 没有可用补丁`); continue; }
|
|
199
|
+
if (ctx.dryRun) { stat.applied += 1; log.info(`[dry-run] apply ${short(sha)} ${info.subject}(${KB(patch.length)})`); continue; }
|
|
200
|
+
const res = git.raw(['-c', 'core.autocrlf=false', 'apply', '--binary', '--whitespace=nowarn', '-'], { cwd: ctx.toDir, input: patch });
|
|
201
|
+
if (res.status === 0) {
|
|
202
|
+
stat.applied += 1;
|
|
203
|
+
log.ok(`已应用 ${short(sha)} ${info.subject}`);
|
|
204
|
+
} else {
|
|
205
|
+
stat.failed += 1;
|
|
206
|
+
log.bad(`应用 ${short(sha)} 失败:${res.stderr.toString('utf8').trim()}`);
|
|
207
|
+
log.info('patch 模式要求目标目录与源 commit 的父版本一致;基线不同请改用 --mode content');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return stat;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function summarize(stat, ctx) {
|
|
214
|
+
log.step('结果');
|
|
215
|
+
if (ctx.mode === 'patch') {
|
|
216
|
+
log.ok(`应用成功 ${stat.applied} 个 commit${stat.failed ? `,失败 ${stat.failed} 个` : ''}`);
|
|
217
|
+
} else {
|
|
218
|
+
log.ok(`写入 ${stat.written} 个,删除 ${stat.deleted} 个,跳过 ${stat.skipped} 个${stat.failed ? `,失败 ${stat.failed} 个` : ''}`);
|
|
219
|
+
}
|
|
220
|
+
if (ctx.backup) log.info(`被覆盖/删除的旧文件已备份到:${path.join(ctx.toDir, '.gfm-backup', ctx.stamp)}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function run(opts) {
|
|
224
|
+
const root = git.findRoot();
|
|
225
|
+
git.setCwd(root);
|
|
226
|
+
|
|
227
|
+
if (!opts.to) {
|
|
228
|
+
throw new CliError('缺少 --to 参数', '用法:git file-manager sync --commits "A..B" --to <目标目录>');
|
|
229
|
+
}
|
|
230
|
+
const mode = opts.mode || 'content';
|
|
231
|
+
if (!MODES.includes(mode)) throw new CliError(`--mode 只能是 ${MODES.join(' / ')},收到:${mode}`);
|
|
232
|
+
|
|
233
|
+
// 只有给了 --from / --last 才把 commit 限制在该分支可达范围内;
|
|
234
|
+
// 否则任何能解析到的 commit 都能同步(包括被 rescue 重写掉、但仍在 backup ref 下的原 sha)
|
|
235
|
+
const useFrom = Boolean(opts.from || opts.last);
|
|
236
|
+
const fromTip = useFrom
|
|
237
|
+
? (opts.from ? git.mustRevParse(opts.from, `--from ${opts.from}`) : git.revParse('HEAD'))
|
|
238
|
+
: undefined;
|
|
239
|
+
const { shas } = parseCommitSpec(opts.commits, {
|
|
240
|
+
last: opts.last,
|
|
241
|
+
inclusive: opts.inclusive,
|
|
242
|
+
fromTip,
|
|
243
|
+
fromLabel: opts.from || 'HEAD',
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const include = toList(opts.paths);
|
|
247
|
+
const exclude = toList(opts.exclude);
|
|
248
|
+
const incMatch = makeMatcher(include);
|
|
249
|
+
const excMatch = makeMatcher(exclude);
|
|
250
|
+
const filters = (file) => (include.length ? incMatch(file) : true) && !excMatch(file);
|
|
251
|
+
|
|
252
|
+
const toDir = path.resolve(process.cwd(), opts.to);
|
|
253
|
+
const ctx = {
|
|
254
|
+
toDir,
|
|
255
|
+
mode,
|
|
256
|
+
dryRun: Boolean(opts.dryRun),
|
|
257
|
+
del: Boolean(opts.del),
|
|
258
|
+
backup: Boolean(opts.backup),
|
|
259
|
+
stamp: backup.stamp(),
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
log.step('sync 计划');
|
|
263
|
+
log.info(`目标目录 : ${toDir}`);
|
|
264
|
+
log.info(`模式 : ${mode}${mode === 'content' ? '(按 commit 时点的内容写入)' : '(把 diff apply 到目标目录)'}`);
|
|
265
|
+
log.info(`commit : ${shas.length} 个 -> ${shas.map(short).join(', ')}`);
|
|
266
|
+
if (include.length) log.info(`--paths : ${include.join(', ')}`);
|
|
267
|
+
if (exclude.length) log.info(`--exclude: ${exclude.join(', ')}`);
|
|
268
|
+
|
|
269
|
+
if (mode === 'patch') {
|
|
270
|
+
if (!ctx.dryRun) fs.mkdirSync(toDir, { recursive: true });
|
|
271
|
+
const stat = applyPatchMode(shas, ctx, include);
|
|
272
|
+
summarize(stat, ctx);
|
|
273
|
+
return stat.failed ? 1 : 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const { entries, merges } = collectChanges(shas, filters);
|
|
277
|
+
if (merges.length) log.warn(`含 merge commit(${merges.map(short).join(', ')}),只取它与第一父提交的差异`);
|
|
278
|
+
if (entries.length === 0) {
|
|
279
|
+
log.warn('这些 commit 里没有匹配的文件改动');
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const plan = planEntries(entries, ctx);
|
|
284
|
+
printPlan(plan, ctx);
|
|
285
|
+
if (ctx.dryRun) {
|
|
286
|
+
log.ok('--dry-run:没有写任何文件');
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
fs.mkdirSync(toDir, { recursive: true });
|
|
290
|
+
const stat = applyPlan(plan, ctx);
|
|
291
|
+
summarize(stat, ctx);
|
|
292
|
+
return stat.failed ? 1 : 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
module.exports = { run, collectChanges, makeMatcher, safeJoin };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* undo —— 把分支指针回滚到最近一次 rescue 开始前。
|
|
5
|
+
* 备份来自 refs/gfm/backup/<时间戳>/<分支>,被 ref 引用的对象不会被 gc 回收。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const git = require('../git');
|
|
9
|
+
const log = require('../log');
|
|
10
|
+
const backup = require('../backup');
|
|
11
|
+
const rescue = require('./rescue');
|
|
12
|
+
|
|
13
|
+
const { CliError } = log;
|
|
14
|
+
|
|
15
|
+
function printBackups() {
|
|
16
|
+
const refs = backup.listBackups();
|
|
17
|
+
if (refs.length === 0) {
|
|
18
|
+
log.warn('没有任何 backup ref(refs/gfm/backup 为空)');
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
log.step('backup refs');
|
|
22
|
+
log.plain(log.renderTable(
|
|
23
|
+
['ref', 'sha', '创建时间'],
|
|
24
|
+
refs.map((r) => [r.ref, String(r.sha).slice(0, 8), r.date || '']),
|
|
25
|
+
));
|
|
26
|
+
const state = backup.readState();
|
|
27
|
+
if (state && state.command === 'rescue') {
|
|
28
|
+
log.plain('');
|
|
29
|
+
log.info(`最近一次 rescue:${state.phase} ${state.startedAt}`);
|
|
30
|
+
log.info(` ${state.from.name} ${String(state.from.tip).slice(0, 8)}`);
|
|
31
|
+
if (state.to) log.info(` ${state.to.name} ${String(state.to.tip).slice(0, 8)}`);
|
|
32
|
+
log.info('回滚:git file-manager undo');
|
|
33
|
+
}
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rollbackToRef(ref) {
|
|
38
|
+
const sha = backup.refSha(ref);
|
|
39
|
+
if (!sha) throw new CliError(`无法解析 backup ref:${ref}`, '用 git file-manager undo --list 查看可用 ref');
|
|
40
|
+
const parts = String(ref).split('/');
|
|
41
|
+
const branch = parts[parts.length - 1];
|
|
42
|
+
if (!git.refExists(`refs/heads/${branch}`)) {
|
|
43
|
+
throw new CliError(`backup ref 对应的分支不存在:${branch}`);
|
|
44
|
+
}
|
|
45
|
+
if (!git.isClean()) throw new CliError('工作区不干净,无法回滚', '先 git checkout -- . 或 git stash');
|
|
46
|
+
const cur = git.currentBranch();
|
|
47
|
+
if (cur === branch) git.checkout(branch, { detach: true });
|
|
48
|
+
git.setBranch(branch, sha);
|
|
49
|
+
git.checkout(branch);
|
|
50
|
+
log.ok(`${branch} -> ${String(sha).slice(0, 8)}`);
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function run(opts) {
|
|
55
|
+
git.setCwd(git.findRoot());
|
|
56
|
+
if (opts.list) return printBackups();
|
|
57
|
+
if (opts.ref) return rollbackToRef(opts.ref);
|
|
58
|
+
|
|
59
|
+
const state = backup.readState();
|
|
60
|
+
if (!state || state.command !== 'rescue') {
|
|
61
|
+
throw new CliError('没有可以撤销的 rescue 现场', '用 git file-manager undo --list 查看历史 backup ref');
|
|
62
|
+
}
|
|
63
|
+
return rescue.abortRescue();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { run, printBackups };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* commit 表达式解析。支持:
|
|
5
|
+
* "A..B" 区间(默认含左端 A;加 --exclusive 则按 git 语义不含 A)
|
|
6
|
+
* "A,B,C" 逐个列举
|
|
7
|
+
* "A..B,C,D..E" 任意混用
|
|
8
|
+
* --last N 从 --from 分支顶端往回数 N 个
|
|
9
|
+
* 结果统一按拓扑顺序(旧 -> 新)排序并去重。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const git = require('./git');
|
|
13
|
+
const { CliError } = require('./log');
|
|
14
|
+
|
|
15
|
+
function splitTerms(spec) {
|
|
16
|
+
return String(spec || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function expandRange(term, inclusive) {
|
|
20
|
+
const idx = term.indexOf('..');
|
|
21
|
+
const left = term.slice(0, idx) || 'HEAD';
|
|
22
|
+
const right = term.slice(idx + 2) || 'HEAD';
|
|
23
|
+
const list = git.lines(['rev-list', '--reverse', `${left}..${right}`]);
|
|
24
|
+
if (inclusive) {
|
|
25
|
+
const leftSha = git.revParse(left);
|
|
26
|
+
if (leftSha && !list.includes(leftSha)) list.unshift(leftSha);
|
|
27
|
+
}
|
|
28
|
+
return list;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function topoSort(shas) {
|
|
32
|
+
const metas = new Map(shas.map((s) => [s, git.commitInfo(s)]));
|
|
33
|
+
return shas.slice().sort((a, b) => {
|
|
34
|
+
if (a === b) return 0;
|
|
35
|
+
if (git.isAncestor(a, b)) return -1;
|
|
36
|
+
if (git.isAncestor(b, a)) return 1;
|
|
37
|
+
const ta = metas.get(a).time;
|
|
38
|
+
const tb = metas.get(b).time;
|
|
39
|
+
return ta - tb || (a < b ? -1 : 1);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} spec 表达式;与 last 二选一
|
|
45
|
+
* @param {object} opts { last, inclusive, fromTip, fromLabel }
|
|
46
|
+
* @returns {{ shas: string[], terms: string[] }}
|
|
47
|
+
*/
|
|
48
|
+
function parseCommitSpec(spec, opts = {}) {
|
|
49
|
+
const { last, inclusive = true, fromTip, fromLabel } = opts;
|
|
50
|
+
if (!last && !spec) {
|
|
51
|
+
throw new CliError('缺少 commit 参数', '用 --commits "A..B" 或 --commits "A,B,C",也可以用 --last N');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const terms = [];
|
|
55
|
+
const picked = new Set();
|
|
56
|
+
|
|
57
|
+
if (last) {
|
|
58
|
+
if (!fromTip) throw new CliError('--last 需要知道起点分支', '请同时给出 --from <分支>');
|
|
59
|
+
const n = Number(last);
|
|
60
|
+
if (!Number.isInteger(n) || n <= 0) throw new CliError(`--last 需要正整数,收到:${last}`);
|
|
61
|
+
const recent = git.lines(['rev-list', `-n`, String(n), fromTip]);
|
|
62
|
+
if (recent.length === 0) throw new CliError(`分支 ${fromLabel || fromTip} 上没有任何 commit`);
|
|
63
|
+
for (const sha of recent) picked.add(sha);
|
|
64
|
+
terms.push(`--last ${n}`);
|
|
65
|
+
} else {
|
|
66
|
+
for (const term of splitTerms(spec)) {
|
|
67
|
+
terms.push(term);
|
|
68
|
+
if (term.includes('...')) {
|
|
69
|
+
throw new CliError(`不支持三点范围:${term}`, '三点范围的含义容易误读,请改用 A..B');
|
|
70
|
+
}
|
|
71
|
+
if (term.includes('..')) {
|
|
72
|
+
for (const sha of expandRange(term, inclusive)) picked.add(sha);
|
|
73
|
+
} else {
|
|
74
|
+
const sha = git.revParse(term);
|
|
75
|
+
if (!sha) throw new CliError(`无法解析为 commit:${term}`);
|
|
76
|
+
picked.add(sha);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (picked.size === 0) {
|
|
82
|
+
const shown = terms.length ? terms.join(' ') : JSON.stringify(String(spec || ''));
|
|
83
|
+
throw new CliError(
|
|
84
|
+
`--commits 没有解析出任何 commit(收到:${shown})`,
|
|
85
|
+
'常见原因:shell 变量没展开或已失效(PowerShell 的 $c1 不跨会话保留)。用 git log --oneline 或 git rev-parse <ref> 重新取 sha,也可以直接用 A..B / HEAD~3 这类表达式',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let ordered;
|
|
90
|
+
if (fromTip) {
|
|
91
|
+
const all = git.lines(['rev-list', '--reverse', fromTip]);
|
|
92
|
+
const allSet = new Set(all);
|
|
93
|
+
const missing = Array.from(picked).filter((s) => !allSet.has(s));
|
|
94
|
+
if (missing.length) {
|
|
95
|
+
throw new CliError(
|
|
96
|
+
`以下 commit 不在分支 ${fromLabel || fromTip} 上:\n ${missing.map(short).join('\n ')}`,
|
|
97
|
+
'请用 --from 指定正确的分支,或检查 commit 是否已被重写',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
ordered = all.filter((s) => picked.has(s));
|
|
101
|
+
} else {
|
|
102
|
+
ordered = topoSort(Array.from(picked));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { shas: ordered, terms };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const short = (sha) => String(sha).slice(0, 8);
|
|
109
|
+
|
|
110
|
+
/** "abcd1234 subject" —— 用于打印计划 */
|
|
111
|
+
function describe(sha) {
|
|
112
|
+
const info = git.commitInfo(sha);
|
|
113
|
+
return `${short(sha)} ${info.subject}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 把连续 commit 折叠成 "a..b" 形式的展示串 */
|
|
117
|
+
function summarize(shas) {
|
|
118
|
+
if (shas.length === 0) return '(空)';
|
|
119
|
+
return `${short(shas[0])} .. ${short(shas[shas.length - 1])}(${shas.length} 个)`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { parseCommitSpec, topoSort, describe, short, summarize };
|
package/src/git.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* git 命令封装。所有调用都走 spawnSync,不引第三方依赖:
|
|
5
|
+
* 参数数组直接传,不经过 shell,路径/中文/空格都安全。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const log = require('./log');
|
|
12
|
+
|
|
13
|
+
const { CliError } = log;
|
|
14
|
+
|
|
15
|
+
let cwd = process.cwd();
|
|
16
|
+
|
|
17
|
+
function setCwd(dir) { cwd = dir; }
|
|
18
|
+
function getCwd() { return cwd; }
|
|
19
|
+
|
|
20
|
+
/** 底层执行,返回 spawnSync 结果(stdout/stderr 为 Buffer) */
|
|
21
|
+
function raw(args, opts = {}) {
|
|
22
|
+
const where = opts.cwd || cwd;
|
|
23
|
+
log.debug(`git ${args.join(' ')} (cwd: ${where})`);
|
|
24
|
+
const res = spawnSync('git', args, {
|
|
25
|
+
cwd: where,
|
|
26
|
+
input: opts.input,
|
|
27
|
+
windowsHide: true,
|
|
28
|
+
maxBuffer: 512 * 1024 * 1024,
|
|
29
|
+
});
|
|
30
|
+
if (res.error) {
|
|
31
|
+
throw new CliError(`无法执行 git:${res.error.message}`, '请确认 git 已安装并在 PATH 中');
|
|
32
|
+
}
|
|
33
|
+
return res;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 执行并校验退出码;allowFail 时返回结果交给调用方判断 */
|
|
37
|
+
function run(args, opts = {}) {
|
|
38
|
+
const res = raw(args, opts);
|
|
39
|
+
if (res.status !== 0 && !opts.allowFail) {
|
|
40
|
+
const stderr = res.stderr.toString('utf8').trim();
|
|
41
|
+
throw new CliError(`git ${args.join(' ')} 执行失败:${stderr ? `\n${stderr}` : '(无错误输出)'}`);
|
|
42
|
+
}
|
|
43
|
+
return { status: res.status, stdout: res.stdout, stderr: res.stderr };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const text = (args, opts) => run(args, opts).stdout.toString('utf8').trim();
|
|
47
|
+
const buffer = (args, opts) => run(args, opts).stdout;
|
|
48
|
+
const check = (args, opts) => raw(args, opts).status === 0;
|
|
49
|
+
|
|
50
|
+
function lines(args, opts) {
|
|
51
|
+
const out = text(args, opts);
|
|
52
|
+
if (!out) return [];
|
|
53
|
+
return out.split('\n').map((s) => s.replace(/\r$/, ''));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function findRoot(dir = cwd) {
|
|
57
|
+
const res = raw(['rev-parse', '--show-toplevel'], { cwd: dir });
|
|
58
|
+
if (res.status !== 0) {
|
|
59
|
+
throw new CliError(`当前目录不是 git 仓库:${dir}`);
|
|
60
|
+
}
|
|
61
|
+
return res.stdout.toString('utf8').trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const gitDir = () => text(['rev-parse', '--absolute-git-dir']);
|
|
65
|
+
|
|
66
|
+
function revParse(ref) {
|
|
67
|
+
const res = raw(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
|
|
68
|
+
if (res.status !== 0) return null;
|
|
69
|
+
return res.stdout.toString('utf8').trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function mustRevParse(ref, label) {
|
|
73
|
+
const sha = revParse(ref);
|
|
74
|
+
if (!sha) throw new CliError(`无法解析为 commit:${label || ref}`);
|
|
75
|
+
return sha;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 当前分支名;detached HEAD 返回 null */
|
|
79
|
+
function currentBranch() {
|
|
80
|
+
const name = text(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
81
|
+
return name === 'HEAD' ? null : name;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const refExists = (ref) => check(['rev-parse', '--verify', '--quiet', ref]);
|
|
85
|
+
const isAncestor = (a, b) => check(['merge-base', '--is-ancestor', a, b]);
|
|
86
|
+
const isClean = () => text(['status', '--porcelain']).length === 0;
|
|
87
|
+
const hasConflicts = () => text(['ls-files', '-u']).length > 0;
|
|
88
|
+
|
|
89
|
+
/** 是否有进行中的 rebase / merge / cherry-pick */
|
|
90
|
+
function inProgress() {
|
|
91
|
+
const dir = gitDir();
|
|
92
|
+
const marks = [
|
|
93
|
+
['rebase-merge', 'rebase'],
|
|
94
|
+
['rebase-apply', 'rebase'],
|
|
95
|
+
['CHERRY_PICK_HEAD', 'cherry-pick'],
|
|
96
|
+
['MERGE_HEAD', 'merge'],
|
|
97
|
+
['REVERT_HEAD', 'revert'],
|
|
98
|
+
];
|
|
99
|
+
const active = [];
|
|
100
|
+
for (const [file, name] of marks) {
|
|
101
|
+
if (fs.existsSync(path.join(dir, file))) active.push(name);
|
|
102
|
+
}
|
|
103
|
+
return Array.from(new Set(active));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function commitInfo(sha) {
|
|
107
|
+
const out = text(['show', '-s', '--format=%H%n%P%n%an%n%ct%n%s', sha]);
|
|
108
|
+
const [head, parents, author, ctime, ...rest] = out.split('\n');
|
|
109
|
+
return {
|
|
110
|
+
sha: head,
|
|
111
|
+
parents: parents.trim() ? parents.trim().split(' ') : [],
|
|
112
|
+
author,
|
|
113
|
+
time: Number(ctime) || 0,
|
|
114
|
+
subject: rest.join('\n'),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 包含该 commit 的远端分支(用于提示「源分支已推送,需要 force-push」) */
|
|
119
|
+
function remoteBranchesContaining(ref) {
|
|
120
|
+
return lines(['branch', '-r', '--contains', ref])
|
|
121
|
+
.map((s) => s.trim())
|
|
122
|
+
.filter((s) => s && !s.includes('->'));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function checkout(ref, opts = {}) {
|
|
126
|
+
const args = ['checkout', '--quiet'];
|
|
127
|
+
if (opts.detach) args.push('--detach');
|
|
128
|
+
args.push(ref);
|
|
129
|
+
return run(args);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const setBranch = (name, sha) => run(['branch', '-f', name, sha]);
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 单步 cherry-pick。返回:
|
|
136
|
+
* { ok: true } 成功
|
|
137
|
+
* { ok: false, conflict: true, ... } 有冲突,等待人工解决
|
|
138
|
+
* { ok: false, empty: true } 内容已存在(空提交),已自动跳过
|
|
139
|
+
*/
|
|
140
|
+
function cherryPick(sha) {
|
|
141
|
+
const res = raw(['cherry-pick', '--no-edit', sha]);
|
|
142
|
+
if (res.status === 0) return { ok: true };
|
|
143
|
+
const stderr = res.stderr.toString('utf8');
|
|
144
|
+
if (hasConflicts()) return { ok: false, conflict: true, sha, stderr };
|
|
145
|
+
if (/empty|nothing to commit|--allow-empty/i.test(stderr)) {
|
|
146
|
+
raw(['cherry-pick', '--skip']);
|
|
147
|
+
return { ok: false, empty: true, sha, stderr };
|
|
148
|
+
}
|
|
149
|
+
throw new CliError(`git cherry-pick ${sha} 失败:\n${stderr.trim()}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function cherryPickContinue() {
|
|
153
|
+
const res = raw(['cherry-pick', '--continue', '--no-edit']);
|
|
154
|
+
if (res.status === 0) return { ok: true };
|
|
155
|
+
const stderr = res.stderr.toString('utf8');
|
|
156
|
+
if (hasConflicts()) return { ok: false, conflict: true, stderr };
|
|
157
|
+
throw new CliError(`git cherry-pick --continue 失败:\n${stderr.trim()}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const cherryPickAbort = () => raw(['cherry-pick', '--abort']);
|
|
161
|
+
|
|
162
|
+
const configGet = (key) => {
|
|
163
|
+
const res = raw(['config', '--get', key]);
|
|
164
|
+
return res.status === 0 ? res.stdout.toString('utf8').trim() : null;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
setCwd,
|
|
169
|
+
getCwd,
|
|
170
|
+
raw,
|
|
171
|
+
run,
|
|
172
|
+
text,
|
|
173
|
+
lines,
|
|
174
|
+
buffer,
|
|
175
|
+
check,
|
|
176
|
+
findRoot,
|
|
177
|
+
gitDir,
|
|
178
|
+
revParse,
|
|
179
|
+
mustRevParse,
|
|
180
|
+
currentBranch,
|
|
181
|
+
refExists,
|
|
182
|
+
isAncestor,
|
|
183
|
+
isClean,
|
|
184
|
+
hasConflicts,
|
|
185
|
+
inProgress,
|
|
186
|
+
commitInfo,
|
|
187
|
+
remoteBranchesContaining,
|
|
188
|
+
checkout,
|
|
189
|
+
setBranch,
|
|
190
|
+
cherryPick,
|
|
191
|
+
cherryPickContinue,
|
|
192
|
+
cherryPickAbort,
|
|
193
|
+
configGet,
|
|
194
|
+
};
|