zen-gitsync 2.9.8 → 2.9.10

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.
@@ -0,0 +1,114 @@
1
+ export function createDiffHelpers({ execGitCommand }) {
2
+ /**
3
+ * 检查文件是否应该跳过diff显示(参考GitLab策略)
4
+ * @param {string} filePath - 文件路径
5
+ * @param {string} diffCommand - 要执行的git diff命令
6
+ * @returns {Promise<{shouldSkip: boolean, reason?: string, stats?: object}>}
7
+ */
8
+ async function checkShouldSkipDiff(filePath, diffCommand) {
9
+ // 1. 检查文件扩展名 - 编译/压缩/二进制文件
10
+ const skipExtensions = /\.(min\.js|umd\.cjs|bundle\.js|dist\.js|prod\.js|map|wasm|exe|dll|so|dylib|bin|zip|tar|gz|rar|7z|jar|war|ear|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|bmp|ico|mp3|mp4|avi|mov|wmv|flv|webm|mkv|ttf|woff|woff2|eot|otf)$/i;
11
+ if (skipExtensions.test(filePath)) {
12
+ return {
13
+ shouldSkip: true,
14
+ reason: '⚠️ 检测到编译/打包/二进制文件,diff已跳过显示。\n\n提示:这类文件通常是自动生成的或二进制文件,不适合查看diff。\n如需查看,请使用命令行。'
15
+ };
16
+ }
17
+
18
+ // 2. 使用 --numstat 快速检查变更量(不获取实际内容,速度快)
19
+ try {
20
+ const numstatCommand = diffCommand.replace(/git (diff|show)/, 'git $1 --numstat');
21
+ const { stdout: numstat } = await execGitCommand(numstatCommand, { log: false });
22
+
23
+ if (numstat.trim()) {
24
+ const lines = numstat.trim().split('\n');
25
+ for (const line of lines) {
26
+ const parts = line.split('\t');
27
+ if (parts.length >= 3) {
28
+ const added = parts[0];
29
+ const deleted = parts[1];
30
+
31
+ // 检查是否是二进制文件(显示为 - -)
32
+ if (added === '-' && deleted === '-') {
33
+ return {
34
+ shouldSkip: true,
35
+ reason: '⚠️ 检测到二进制文件,diff已跳过显示。\n\n提示:二进制文件无法以文本形式显示diff。'
36
+ };
37
+ }
38
+
39
+ // 检查变更行数是否过多(超过3000行)
40
+ const totalChanges = parseInt(added) + parseInt(deleted);
41
+ if (!isNaN(totalChanges) && totalChanges > 3000) {
42
+ return {
43
+ shouldSkip: true,
44
+ reason: `⚠️ 变更内容过大 (${totalChanges.toLocaleString()} 行变更),diff已跳过显示以避免浏览器卡顿。\n\n提示:建议使用命令行或专业diff工具查看大文件变更。\n增加:${parseInt(added).toLocaleString()} 行\n删除:${parseInt(deleted).toLocaleString()} 行`,
45
+ stats: { added: parseInt(added), deleted: parseInt(deleted), total: totalChanges }
46
+ };
47
+ }
48
+ }
49
+ }
50
+ }
51
+ } catch (error) {
52
+ // numstat失败不影响后续流程
53
+ console.log('numstat检查失败,继续执行:', error.message);
54
+ }
55
+
56
+ // 3. 通过了初步检查
57
+ return { shouldSkip: false };
58
+ }
59
+
60
+ /**
61
+ * 检查diff内容大小,如果过大则跳过
62
+ * @param {string} diffContent - diff内容
63
+ * @param {number} maxSizeKB - 最大大小(KB),默认500KB
64
+ * @returns {object|null} - 如果需要跳过返回提示对象,否则返回null
65
+ */
66
+ function checkDiffSize(diffContent, maxSizeKB = 500) {
67
+ const diffSizeKB = Buffer.byteLength(diffContent, 'utf8') / 1024;
68
+ if (diffSizeKB > maxSizeKB) {
69
+ return {
70
+ diff: `⚠️ Diff内容过大 (${diffSizeKB.toFixed(1)} KB),已跳过显示以避免浏览器卡顿。\n\n提示:建议使用命令行查看大文件diff。`,
71
+ isLargeFile: true,
72
+ size: diffSizeKB
73
+ };
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * 从 diff 内容中统计增加和删除行数
80
+ * @param {string} diffContent - diff内容
81
+ * @returns {object} - {added, deleted}
82
+ */
83
+ function getDiffStats(diffContent) {
84
+ if (!diffContent) return { added: 0, deleted: 0 };
85
+
86
+ const lines = diffContent.split('\n');
87
+ let added = 0;
88
+ let deleted = 0;
89
+
90
+ for (const line of lines) {
91
+ // 跳过diff头部信息
92
+ if (line.startsWith('diff ') || line.startsWith('index ') ||
93
+ line.startsWith('--- ') || line.startsWith('+++ ') ||
94
+ line.startsWith('@@ ')) {
95
+ continue;
96
+ }
97
+
98
+ // 统计增加和删除的行
99
+ if (line.startsWith('+')) {
100
+ added++;
101
+ } else if (line.startsWith('-')) {
102
+ deleted++;
103
+ }
104
+ }
105
+
106
+ return { added, deleted };
107
+ }
108
+
109
+ return {
110
+ checkShouldSkipDiff,
111
+ checkDiffSize,
112
+ getDiffStats
113
+ };
114
+ }
@@ -0,0 +1,481 @@
1
+ import { createDiffHelpers } from './diffUtils.js';
2
+
3
+ export function registerGitStashRoutes({ app, execGitCommand, configManager }) {
4
+ const { checkShouldSkipDiff, checkDiffSize, getDiffStats } = createDiffHelpers({ execGitCommand });
5
+
6
+ // 获取stash列表
7
+ app.get('/api/stash-list', async (req, res) => {
8
+ try {
9
+ const { stdout } = await execGitCommand('git stash list');
10
+
11
+ // 解析stash列表
12
+ const stashList = stdout.split('\n')
13
+ .filter(Boolean)
14
+ .map(line => {
15
+ // 尝试解析stash行,格式类似: stash@{0}: WIP on branch: commit message
16
+ const match = line.match(/^(stash@\{\d+\}): (.+)$/);
17
+ if (match) {
18
+ return {
19
+ id: match[1],
20
+ description: match[2]
21
+ };
22
+ }
23
+ return null;
24
+ })
25
+ .filter(item => item !== null);
26
+
27
+ res.json({ success: true, stashes: stashList });
28
+ } catch (error) {
29
+ console.error('获取stash列表失败:', error);
30
+ res.status(500).json({ success: false, error: error.message });
31
+ }
32
+ });
33
+
34
+ // 创建新的stash
35
+ app.post('/api/stash-save', async (req, res) => {
36
+ try {
37
+ const { message, includeUntracked, excludeLocked } = req.body;
38
+
39
+ if (excludeLocked) {
40
+ const lockedFiles = await configManager.getLockedFiles();
41
+ // 包含未跟踪文件,确保状态与 UI 一致
42
+ const { stdout: statusStdout } = await execGitCommand('git status --porcelain --untracked-files=all', { log: false });
43
+ const changedFiles = statusStdout
44
+ .split('\n')
45
+ .filter(line => line.trim())
46
+ .map(line => {
47
+ const match = line.match(/^(..)\s+(.+)$/);
48
+ if (match) {
49
+ const status = match[1];
50
+ let filename = match[2];
51
+ if (filename.startsWith('"') && filename.endsWith('"')) {
52
+ filename = filename.slice(1, -1).replace(/\\(.)/g, '$1');
53
+ }
54
+ return { status, filename };
55
+ }
56
+ return null;
57
+ })
58
+ .filter(Boolean);
59
+
60
+ const path = (await import('path')).default;
61
+ const fs = (await import('fs')).default;
62
+
63
+ // 过滤出未锁定且需要包含在 stash 中的路径
64
+ // 修复:当 includeUntracked === true 且变更项是“新目录”时,不能直接把目录作为 pathspec
65
+ // 否则会把目录里的“锁定文件”一起打入 stash。这里将目录展开为具体文件,并逐个过滤锁定路径。
66
+ const filesToStashSet = new Set();
67
+ for (const item of changedFiles) {
68
+ const { status, filename } = item;
69
+ const normalizedFile = path.normalize(filename);
70
+
71
+ // 检查是否被锁定
72
+ const isLocked = lockedFiles.some(locked => {
73
+ const normalizedLocked = path.normalize(locked);
74
+ return normalizedFile === normalizedLocked || normalizedFile.startsWith(normalizedLocked + path.sep);
75
+ });
76
+
77
+ if (!isLocked) {
78
+ try {
79
+ const fullPath = path.resolve(filename);
80
+ const stats = fs.statSync(fullPath);
81
+ // 1) 已存在的普通文件:直接加入
82
+ if (stats.isFile()) {
83
+ filesToStashSet.add(filename);
84
+ } else if (stats.isDirectory()) {
85
+ // 2) 目录:当勾选了 includeUntracked 时,展开目录下的文件(包含未跟踪和已跟踪修改)
86
+ if (includeUntracked) {
87
+ try {
88
+ // 使用 git 列出该目录下的未跟踪和已修改文件
89
+ const { stdout: listStdout } = await execGitCommand(`git ls-files -mo --exclude-standard "${filename}"`, { log: false });
90
+ const listed = listStdout
91
+ .split('\n')
92
+ .map(l => l.trim())
93
+ .filter(Boolean)
94
+ // 仅保留该目录下的条目
95
+ .filter(p => {
96
+ const n = path.normalize(p);
97
+ const base = path.normalize(filename);
98
+ return n === base || n.startsWith(base + path.sep);
99
+ });
100
+ for (const p of listed) {
101
+ const n = path.normalize(p);
102
+ const locked = lockedFiles.some(locked => {
103
+ const nl = path.normalize(locked);
104
+ return n === nl || n.startsWith(nl + path.sep);
105
+ });
106
+ if (!locked) {
107
+ filesToStashSet.add(p);
108
+ }
109
+ }
110
+ } catch (_) {
111
+ // 如果 git 列举失败,退化为不处理该目录
112
+ }
113
+ }
114
+ }
115
+ } catch (error) {
116
+ // 3) 文件系统不可达的情况
117
+ // 对于已删除的文件(D状态),我们仍然需要包含它们
118
+ if (status.includes('D')) {
119
+ filesToStashSet.add(filename);
120
+ }
121
+ // 其他情况(如路径不存在且不是删除状态)则跳过
122
+ }
123
+ }
124
+ }
125
+
126
+ let filesToStash = Array.from(filesToStashSet);
127
+ if (filesToStash.length === 0) {
128
+ return res.json({ success: false, message: '所有更改都是锁定文件,无需储藏' });
129
+ }
130
+
131
+ // 在执行 stash 前进行候选校验:
132
+ // 1) 仍有跟踪差异的文件
133
+ try {
134
+ const args = filesToStash.map(f => `"${f}"`).join(' ');
135
+ const { stdout: diffNames } = await execGitCommand(`git diff --name-only -- ${args}`, { log: false });
136
+ const trackedChanged = new Set(diffNames.split('\n').map(s => s.trim()).filter(Boolean));
137
+
138
+ // 2) 仍为未跟踪的文件(当 includeUntracked 才检查)
139
+ let untrackedExisting = new Set();
140
+ if (includeUntracked) {
141
+ const { stdout: others } = await execGitCommand(`git ls-files --others --exclude-standard -- ${args}`, { log: false });
142
+ untrackedExisting = new Set(others.split('\n').map(s => s.trim()).filter(Boolean));
143
+ }
144
+
145
+ // 合并有效集合
146
+ const validSet = new Set();
147
+ for (const f of filesToStash) {
148
+ if (trackedChanged.has(f) || untrackedExisting.has(f)) {
149
+ validSet.add(f);
150
+ }
151
+ }
152
+
153
+ filesToStash = Array.from(validSet);
154
+ } catch (e) {
155
+ // 校验失败不应中断主流程,保守继续使用原集合
156
+ console.warn('候选文件有效性校验失败(将继续尝试储藏):', e?.message || e);
157
+ }
158
+
159
+ if (filesToStash.length === 0) {
160
+ return res.json({ success: false, message: '没有可储藏的更改(可能刚刚已储藏,或被锁定过滤)' });
161
+ }
162
+
163
+ let command = 'git stash push';
164
+ if (message) command += ` -m "${message}"`;
165
+ if (includeUntracked) command += ' --include-untracked';
166
+ const args = filesToStash.map(f => `"${f}"`).join(' ');
167
+ command += ` -- ${args}`;
168
+
169
+ const { stdout } = await execGitCommand(command);
170
+ if (stdout.includes('No local changes to save')) {
171
+ return res.json({ success: false, message: '没有本地更改需要保存' });
172
+ }
173
+ return res.json({ success: true, message: '成功保存未锁定的工作区更改', output: stdout });
174
+ }
175
+
176
+ let command = 'git stash push';
177
+ if (message) {
178
+ command += ` -m "${message}"`;
179
+ }
180
+ if (includeUntracked) {
181
+ command += ' --include-untracked';
182
+ }
183
+ const { stdout } = await execGitCommand(command);
184
+ if (stdout.includes('No local changes to save')) {
185
+ return res.json({ success: false, message: '没有本地更改需要保存' });
186
+ }
187
+ res.json({ success: true, message: '成功保存工作区更改', output: stdout });
188
+ } catch (error) {
189
+ // 友好处理:当 Git 返回 "No valid patches in input" 时,提示无可储藏更改
190
+ const msg = error?.message || '';
191
+ if (msg.includes('No valid patches in input')) {
192
+ return res.json({ success: false, message: '没有可储藏的更改(可能刚刚已储藏,或被锁定过滤)' });
193
+ }
194
+ console.error('保存stash失败:', error);
195
+ res.status(500).json({ success: false, error: error.message });
196
+ }
197
+ });
198
+
199
+ // 保存部分文件的stash
200
+ app.post('/api/stash-save-partial', async (req, res) => {
201
+ try {
202
+ const { files, message, includeUntracked } = req.body;
203
+
204
+ if (!files || !Array.isArray(files) || files.length === 0) {
205
+ return res.json({ success: false, message: '请选择要储藏的文件' });
206
+ }
207
+
208
+ // 构建 git stash push 命令
209
+ let command = 'git stash push';
210
+ if (message) {
211
+ command += ` -m "${message}"`;
212
+ }
213
+ if (includeUntracked) {
214
+ command += ' --include-untracked';
215
+ }
216
+
217
+ // 添加文件列表
218
+ const args = files.map(f => `"${f}"`).join(' ');
219
+ command += ` -- ${args}`;
220
+
221
+ const { stdout } = await execGitCommand(command);
222
+
223
+ if (stdout.includes('No local changes to save')) {
224
+ return res.json({ success: false, message: '没有本地更改需要保存' });
225
+ }
226
+
227
+ res.json({
228
+ success: true,
229
+ message: `成功储藏 ${files.length} 个文件`,
230
+ output: stdout
231
+ });
232
+ } catch (error) {
233
+ const msg = error?.message || '';
234
+ if (msg.includes('No valid patches in input')) {
235
+ return res.json({ success: false, message: '没有可储藏的更改' });
236
+ }
237
+ console.error('保存部分stash失败:', error);
238
+ res.status(500).json({ success: false, error: error.message });
239
+ }
240
+ });
241
+
242
+ // 应用特定的stash
243
+ app.post('/api/stash-apply', async (req, res) => {
244
+ try {
245
+ const { stashId, pop } = req.body;
246
+
247
+ if (!stashId) {
248
+ return res.status(400).json({
249
+ success: false,
250
+ error: '缺少stash ID参数'
251
+ });
252
+ }
253
+
254
+ // 决定是使用apply(保留stash)还是pop(应用后删除stash)
255
+ const command = pop ? `git stash pop ${stashId}` : `git stash apply ${stashId}`;
256
+
257
+ try {
258
+ const { stdout } = await execGitCommand(command);
259
+
260
+ res.json({
261
+ success: true,
262
+ message: `成功${pop ? '应用并删除' : '应用'}stash`,
263
+ output: stdout
264
+ });
265
+ } catch (error) {
266
+ // 检查是否有合并冲突
267
+ if (error.message && error.message.includes('CONFLICT')) {
268
+ return res.status(409).json({
269
+ success: false,
270
+ hasConflicts: true,
271
+ error: '应用stash时发生冲突,需要手动解决',
272
+ details: error.message
273
+ });
274
+ }
275
+ throw error;
276
+ }
277
+ } catch (error) {
278
+ console.error('应用stash失败:', error);
279
+ res.status(500).json({ success: false, error: error.message });
280
+ }
281
+ });
282
+
283
+ // 删除特定的stash
284
+ app.post('/api/stash-drop', async (req, res) => {
285
+ try {
286
+ const { stashId } = req.body;
287
+
288
+ if (!stashId) {
289
+ return res.status(400).json({
290
+ success: false,
291
+ error: '缺少stash ID参数'
292
+ });
293
+ }
294
+
295
+ const { stdout } = await execGitCommand(`git stash drop ${stashId}`);
296
+
297
+ res.json({
298
+ success: true,
299
+ message: '成功删除stash',
300
+ output: stdout
301
+ });
302
+ } catch (error) {
303
+ console.error('删除stash失败:', error);
304
+ res.status(500).json({ success: false, error: error.message });
305
+ }
306
+ });
307
+
308
+ // 清空所有stash
309
+ app.post('/api/stash-clear', async (req, res) => {
310
+ try {
311
+ const { stdout } = await execGitCommand('git stash clear');
312
+
313
+ res.json({
314
+ success: true,
315
+ message: '成功清空所有stash',
316
+ output: stdout
317
+ });
318
+ } catch (error) {
319
+ console.error('清空stash失败:', error);
320
+ res.status(500).json({ success: false, error: error.message });
321
+ }
322
+ });
323
+
324
+ // 获取stash中的文件列表(包含未跟踪文件)
325
+ app.get('/api/stash-files', async (req, res) => {
326
+ try {
327
+ const { stashId } = req.query;
328
+
329
+ if (!stashId) {
330
+ return res.status(400).json({
331
+ success: false,
332
+ error: '缺少stash ID参数'
333
+ });
334
+ }
335
+
336
+ console.log(`获取stash文件列表: stashId=${stashId}`);
337
+
338
+ // 0) 解析出当前 stash 提交及其父提交哈希,避免在 Windows 上使用 ^ 语法
339
+ const { stdout: parentsLine } = await execGitCommand(`git rev-list --parents -n 1 ${stashId}`, { log: false });
340
+ const hashes = parentsLine.trim().split(/\s+/).filter(Boolean);
341
+ const stashCommit = hashes[0] || '';
342
+ const parent1 = hashes[1] || '';
343
+ const parent3 = hashes[3] || ''; // 当包含未跟踪文件时,第三父才存在
344
+
345
+ // 1) 跟踪文件的变更列表:父1 与 stash 提交的差异(若无父1则为空)
346
+ let trackedFiles = [];
347
+ if (parent1) {
348
+ const { stdout: trackedOut } = await execGitCommand(`git diff --name-only ${parent1} ${stashCommit}`, { log: false });
349
+ trackedFiles = trackedOut.split('\n').map(s => s.trim()).filter(Boolean);
350
+ }
351
+
352
+ // 2) 未跟踪文件:来自第三父(若存在)
353
+ let untrackedFiles = [];
354
+ if (parent3) {
355
+ const { stdout: untrackedOut } = await execGitCommand(`git ls-tree -r --name-only ${parent3}`, { log: false });
356
+ untrackedFiles = untrackedOut.split('\n').map(s => s.trim()).filter(Boolean);
357
+ }
358
+
359
+ // 合并并去重
360
+ const fileSet = new Set([ ...trackedFiles, ...untrackedFiles ]);
361
+ const files = Array.from(fileSet);
362
+ console.log(`找到${files.length}个stash文件(含未跟踪):`, files);
363
+
364
+ res.json({
365
+ success: true,
366
+ files
367
+ });
368
+ } catch (error) {
369
+ console.error('获取stash文件列表失败:', error);
370
+ res.status(500).json({
371
+ success: false,
372
+ error: `获取stash文件列表失败: ${error.message}`
373
+ });
374
+ }
375
+ });
376
+
377
+ // 获取stash中特定文件的差异(包含未跟踪文件)
378
+ app.get('/api/stash-file-diff', async (req, res) => {
379
+ try {
380
+ const { stashId, file } = req.query;
381
+
382
+ if (!stashId || !file) {
383
+ return res.status(400).json({
384
+ success: false,
385
+ error: '缺少必要参数'
386
+ });
387
+ }
388
+
389
+ console.log(`获取stash文件差异: stashId=${stashId}, file=${file}`);
390
+
391
+ // 先解析父提交哈希,避免使用 ^ 语法
392
+ const { stdout: parentsLine } = await execGitCommand(`git rev-list --parents -n 1 ${stashId}`, { log: false });
393
+ const hashes = parentsLine.trim().split(/\s+/).filter(Boolean);
394
+ const stashCommit = hashes[0] || '';
395
+ const parent1 = hashes[1] || '';
396
+ const parent3 = hashes[3] || '';
397
+
398
+ // 检查该文件是否来自第三父(未跟踪文件)
399
+ let isFromThirdParent = false;
400
+ if (parent3) {
401
+ try {
402
+ await execGitCommand(`git cat-file -e ${parent3}:"${file}"`, { log: false });
403
+ isFromThirdParent = true;
404
+ } catch (_) {
405
+ isFromThirdParent = false;
406
+ }
407
+ }
408
+
409
+ if (isFromThirdParent) {
410
+ // 未跟踪文件:读取第三父中的内容,构造新增文件的统一diff
411
+ const { stdout: blob } = await execGitCommand(`git show ${parent3}:"${file}"`, { log: false });
412
+
413
+ // 检查文件大小
414
+ const sizeCheck = checkDiffSize(blob, 500);
415
+ if (sizeCheck) {
416
+ return res.json({ success: true, ...sizeCheck });
417
+ }
418
+
419
+ const lines = blob.endsWith('\n') ? blob.slice(0, -1).split('\n') : blob.split('\n');
420
+ const lineCount = lines.length;
421
+
422
+ // 检查行数
423
+ if (lineCount > 10000) {
424
+ return res.json({
425
+ success: true,
426
+ diff: `⚠️ 变更内容过大 (${lineCount.toLocaleString()} 行),diff已跳过显示以避免浏览器卡顿。\n\n提示:建议使用命令行查看大文件变更。`,
427
+ isLargeFile: true,
428
+ stats: { added: lineCount, deleted: 0, total: lineCount }
429
+ });
430
+ }
431
+
432
+ const plusLines = lines.map(l => `+${l}`).join('\n');
433
+ const diffText = [
434
+ `diff --git a/${file} b/${file}`,
435
+ `new file mode 100644`,
436
+ `--- /dev/null`,
437
+ `+++ b/${file}`,
438
+ `@@ -0,0 +${lineCount} @@`,
439
+ `${plusLines}`
440
+ ].join('\n');
441
+
442
+ return res.json({ success: true, diff: diffText });
443
+ }
444
+
445
+ // 否则,使用原有方式获取与父1的变更
446
+ const diffCommand = `git show ${stashCommit} -- "${file}"`;
447
+
448
+ // 使用优化的检查函数
449
+ const skipCheck = await checkShouldSkipDiff(file, diffCommand);
450
+ if (skipCheck.shouldSkip) {
451
+ return res.json({
452
+ success: true,
453
+ diff: skipCheck.reason,
454
+ isLargeFile: true,
455
+ stats: skipCheck.stats
456
+ });
457
+ }
458
+
459
+ const { stdout } = await execGitCommand(diffCommand);
460
+
461
+ console.log(`获取到差异内容,长度: ${stdout.length}`);
462
+
463
+ // 检查实际diff大小
464
+ const sizeCheck = checkDiffSize(stdout, 500);
465
+ if (sizeCheck) {
466
+ return res.json({ success: true, ...sizeCheck });
467
+ }
468
+
469
+ // 统计增加和删除行数
470
+ const stats = getDiffStats(stdout);
471
+
472
+ res.json({ success: true, diff: stdout, stats });
473
+ } catch (error) {
474
+ console.error('获取stash文件差异失败:', error);
475
+ res.status(500).json({
476
+ success: false,
477
+ error: `获取stash文件差异失败: ${error.message}`
478
+ });
479
+ }
480
+ });
481
+ }