zen-gitsync 2.16.48 → 2.16.49
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/package.json +1 -1
- package/src/ui/public/assets/{AgentView-BoEzb8Nb.js → AgentView-CZhZ-RtZ.js} +1 -1
- package/src/ui/public/assets/{AppVersionBadge-CsP7l4ne.js → AppVersionBadge-CGLBfOhF.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-BYBycv-a.js → BranchSelector-DO0xkvYF.js} +1 -1
- package/src/ui/public/assets/{CommitForm-DzVZxIIr.js → CommitForm-ickhPe9o.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-D2T9hYSC.js → CommonDialog-CliNZp_i.js} +1 -1
- package/src/ui/public/assets/{EditorView-RfbE5b0z.js → EditorView-Bj5Buc8r.js} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-CLm6Q0o4.js → FlowExecutionViewer-CuID17Kg.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-CVqtNs-K.js → FlowOrchestrationWorkspace-BWynSUhQ.js} +1 -1
- package/src/ui/public/assets/{LogList-CpWhG0bb.js → LogList-C8fYaGlz.js} +1 -1
- package/src/ui/public/assets/{MindmapView-CIRBJmAL.js → MindmapView-Co2ys9e3.js} +1 -1
- package/src/ui/public/assets/{MonitorView-DXSGBi3r.js → MonitorView-BxFwNYjB.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-tD45E4sx.js → ProjectStartupButton-DKspkGJC.js} +1 -1
- package/src/ui/public/assets/{RemoteManagerDialog-B3hrAiEG.js → RemoteManagerDialog-CRq596ya.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-BEYVnotw.js → RemoteRepoCard-9_Y3iDT6.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-C85LmOXa.js → SourceMapView-CFv9JUGZ.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-BuyQHvwg.js → SvgIcon-koi3MuB8.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-BYwFGCop.js → UserInputNode-CUTCIcEc.js} +1 -1
- package/src/ui/public/assets/{WorkbenchView-B2LoICiH.js → WorkbenchView-COoRqTST.js} +1 -1
- package/src/ui/public/assets/{_plugin-vue_export-helper-CtcBeUZZ.js → _plugin-vue_export-helper-VkarRGOe.js} +4 -2
- package/src/ui/public/assets/{configStore-Ceu0TxZJ.js → configStore-vksEBmTc.js} +1 -1
- package/src/ui/public/assets/index-C3zoLat6.css +1 -0
- package/src/ui/public/assets/{index-BrshD3VG.js → index-Cn4CA40E.js} +56 -51
- package/src/ui/public/index.html +6 -6
- package/src/ui/server/routes/fs.js +24 -0
- package/src/ui/server/utils/directoryGitState.js +209 -0
- package/src/ui/server/utils/directoryGitState.test.js +163 -0
- package/src/ui/public/assets/index-BF9NCKhe.css +0 -1
package/src/ui/public/index.html
CHANGED
|
@@ -10,18 +10,18 @@
|
|
|
10
10
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
11
11
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
12
12
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
13
|
-
<script type="module" crossorigin src="/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-Cn4CA40E.js"></script>
|
|
14
14
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-BM3Ffeng.js">
|
|
15
15
|
<link rel="modulepreload" crossorigin href="/assets/monaco-Dje8Rg13.js">
|
|
16
16
|
<link rel="modulepreload" crossorigin href="/assets/element-plus-C3mePHEv.js">
|
|
17
17
|
<link rel="modulepreload" crossorigin href="/assets/vendor-EBzJAuo1.js">
|
|
18
|
-
<link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-
|
|
18
|
+
<link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-VkarRGOe.js">
|
|
19
19
|
<link rel="modulepreload" crossorigin href="/assets/socket-io-CGezBd--.js">
|
|
20
20
|
<link rel="modulepreload" crossorigin href="/assets/vue-flow-DB6byJul.js">
|
|
21
21
|
<link rel="modulepreload" crossorigin href="/assets/flow-mindmap-BzX-PWap.js">
|
|
22
|
-
<link rel="modulepreload" crossorigin href="/assets/CommonDialog-
|
|
23
|
-
<link rel="modulepreload" crossorigin href="/assets/SvgIcon-
|
|
24
|
-
<link rel="modulepreload" crossorigin href="/assets/configStore-
|
|
22
|
+
<link rel="modulepreload" crossorigin href="/assets/CommonDialog-CliNZp_i.js">
|
|
23
|
+
<link rel="modulepreload" crossorigin href="/assets/SvgIcon-koi3MuB8.js">
|
|
24
|
+
<link rel="modulepreload" crossorigin href="/assets/configStore-vksEBmTc.js">
|
|
25
25
|
<link rel="modulepreload" crossorigin href="/assets/toolsStore-CHBnAWFM.js">
|
|
26
26
|
<link rel="stylesheet" crossorigin href="/assets/monaco-B2lPvkrc.css">
|
|
27
27
|
<link rel="stylesheet" crossorigin href="/assets/element-plus-D3Dn7nX0.css">
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
<link rel="stylesheet" crossorigin href="/assets/flow-mindmap-DnzM9SlK.css">
|
|
31
31
|
<link rel="stylesheet" crossorigin href="/assets/CommonDialog-jP0E9mco.css">
|
|
32
32
|
<link rel="stylesheet" crossorigin href="/assets/SvgIcon-CDNsk4-Q.css">
|
|
33
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
33
|
+
<link rel="stylesheet" crossorigin href="/assets/index-C3zoLat6.css">
|
|
34
34
|
</head>
|
|
35
35
|
<body>
|
|
36
36
|
<div id="app"></div>
|
|
@@ -22,6 +22,7 @@ import os from 'os';
|
|
|
22
22
|
import { spawn, exec, execSync } from 'child_process';
|
|
23
23
|
import { ensureWithinCwd, normalizeProjectPath } from '../utils/pathGuard.js';
|
|
24
24
|
import { asyncRoute, HttpError } from '../utils/asyncRoute.js';
|
|
25
|
+
import { probeDirectoryGitStates, normalizeDirKey } from '../utils/directoryGitState.js';
|
|
25
26
|
import { invalidateCurrentProjectKey, invalidateRawConfigCache } from '../../../config.js';
|
|
26
27
|
import { invalidateCwdCache } from '../../../utils/index.js';
|
|
27
28
|
|
|
@@ -83,6 +84,8 @@ export function registerFsRoutes({
|
|
|
83
84
|
setProjectRoomId,
|
|
84
85
|
setIsGitRepo
|
|
85
86
|
}) {
|
|
87
|
+
// 单次 Git 状态探测的路径数上限(最近目录一般十几个,这里是防御性上限)
|
|
88
|
+
const MAX_GIT_PROBE_PATHS = 100;
|
|
86
89
|
// ── 解析并校验 user 输入路径在当前项目 cwd 内(防 ../ 父目录逃逸、startsWith 假阳性、Windows 大小写)──
|
|
87
90
|
const safePathInProject = async (userPath) => {
|
|
88
91
|
const cwd = getCurrentProjectPath() || process.cwd()
|
|
@@ -380,6 +383,27 @@ export function registerFsRoutes({
|
|
|
380
383
|
}
|
|
381
384
|
}));
|
|
382
385
|
|
|
386
|
+
// 批量探测"最近目录"的 Git 状态(是不是仓库 / 有没有未提交改动)
|
|
387
|
+
//
|
|
388
|
+
// 列表卡片用它标 Git 徽标。安全上只接受**配置里的最近目录**:否则等于给前端开了
|
|
389
|
+
// 一个"任意路径 git 探测"的口子,可以拿来判断磁盘上任意目录存在与否以及仓库状态。
|
|
390
|
+
// body.paths 可选:不传就探测全部最近目录(面板场景),传了就只探这些。
|
|
391
|
+
app.post('/api/recent_directories/git-state', asyncRoute(async (req, res) => {
|
|
392
|
+
const recentDirs = (await configManager.getRecentDirectories()) || [];
|
|
393
|
+
const allowed = new Set(recentDirs.map(normalizeDirKey));
|
|
394
|
+
|
|
395
|
+
const requested = Array.isArray(req.body?.paths) ? req.body.paths : recentDirs;
|
|
396
|
+
const targets = [];
|
|
397
|
+
for (const p of requested.slice(0, MAX_GIT_PROBE_PATHS)) {
|
|
398
|
+
if (typeof p !== 'string' || !p.trim()) continue;
|
|
399
|
+
if (!allowed.has(normalizeDirKey(p))) continue; // 不在最近目录白名单里 → 忽略
|
|
400
|
+
targets.push(p);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const results = await probeDirectoryGitStates(targets);
|
|
404
|
+
res.json({ success: true, results });
|
|
405
|
+
}));
|
|
406
|
+
|
|
383
407
|
// 在资源管理器/访达中打开当前目录
|
|
384
408
|
app.post('/api/open_directory', asyncRoute(async (req, res) => {
|
|
385
409
|
try {
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// 批量探测目录的 Git 状态(是不是仓库 / 有没有未提交改动)。
|
|
16
|
+
//
|
|
17
|
+
// 用途:「最近项目」「常用目录」列表要在卡片上标出 Git 状态,列表里可能有十几个目录,
|
|
18
|
+
// 因此这里做三件事:
|
|
19
|
+
// 1. 每个目录只跑**一次** git(status 成功即说明是仓库,失败即非仓库),
|
|
20
|
+
// 避免 rev-parse + status 两次 spawn;
|
|
21
|
+
// 2. 并发上限 + 单目录超时,别让某个大仓库(或网络盘)拖死整个请求;
|
|
22
|
+
// 3. 结果短 TTL 缓存,弹窗反复打开不必重复扫盘。
|
|
23
|
+
//
|
|
24
|
+
// 不用 src/utils/index.js 的 execGitCommand:那个函数固定跑在当前项目 cwd,
|
|
25
|
+
// 且会把每次调用写进命令历史和 socket 广播 —— 探测十几个无关目录不该污染这些。
|
|
26
|
+
import { execFile } from 'node:child_process';
|
|
27
|
+
import fs from 'node:fs/promises';
|
|
28
|
+
|
|
29
|
+
/** 单目录 git status 超时。大仓库 status 通常 <300ms,5s 已是宽容上限 */
|
|
30
|
+
export const DEFAULT_PROBE_TIMEOUT_MS = 5000;
|
|
31
|
+
/** 结果缓存 TTL。用户连续开几次弹窗时命中缓存,不必重复扫盘 */
|
|
32
|
+
export const DEFAULT_CACHE_TTL_MS = 15000;
|
|
33
|
+
/** 并发上限:并发开满会在慢盘/大仓库上互相抢 IO,4 是实测比较稳的值 */
|
|
34
|
+
export const DEFAULT_CONCURRENCY = 4;
|
|
35
|
+
|
|
36
|
+
const MAX_BUFFER = 8 * 1024 * 1024;
|
|
37
|
+
const cache = new Map(); // key: normalizeDirKey(path) → { ts, state }
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 路径缓存键。Windows 上同一目录可能以 `D:/x`、`d:\x`、`d:\x\` 多种写法出现,
|
|
41
|
+
* 统一成小写 + 正斜杠 + 去尾斜杠,避免同一目录缓存成多份。
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeDirKey(dirPath) {
|
|
44
|
+
return String(dirPath || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 空状态(非仓库 / 探测失败时复用),避免各处手写零值 */
|
|
48
|
+
function emptyState(extra = {}) {
|
|
49
|
+
return { exists: true, isGitRepo: false, changed: 0, staged: 0, unstaged: 0, untracked: 0, ...extra };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 解析 `git status --porcelain=v1` 输出 → 计数。
|
|
54
|
+
* 纯函数,单测覆盖。
|
|
55
|
+
*
|
|
56
|
+
* 格式:`XY path`,X=暂存区状态,Y=工作区状态。
|
|
57
|
+
* - `??` → 未跟踪
|
|
58
|
+
* - `!!` → 被忽略(porcelain 默认不输出,防御性跳过)
|
|
59
|
+
* - 其余非空位 → 计入暂存/未暂存(冲突如 `UU` 会同时计入两边)
|
|
60
|
+
* `changed` 按**条目数**数,所以"已暂存又被修改"的文件只算 1 个,不重复计数。
|
|
61
|
+
*/
|
|
62
|
+
export function parsePorcelainStatus(stdout) {
|
|
63
|
+
const result = { changed: 0, staged: 0, unstaged: 0, untracked: 0 };
|
|
64
|
+
for (const rawLine of String(stdout || '').split('\n')) {
|
|
65
|
+
const line = rawLine.replace(/\r$/, '');
|
|
66
|
+
if (line.length < 3) continue;
|
|
67
|
+
const x = line[0];
|
|
68
|
+
const y = line[1];
|
|
69
|
+
if (x === '!' && y === '!') continue;
|
|
70
|
+
if (x === '?' && y === '?') {
|
|
71
|
+
result.untracked += 1;
|
|
72
|
+
} else {
|
|
73
|
+
if (x !== ' ' && x !== '?') result.staged += 1;
|
|
74
|
+
if (y !== ' ' && y !== '?') result.unstaged += 1;
|
|
75
|
+
}
|
|
76
|
+
result.changed += 1;
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function execFileAsync(file, args, options) {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
execFile(file, args, options, (error, stdout, stderr) => {
|
|
84
|
+
if (error) {
|
|
85
|
+
error.stdout = stdout;
|
|
86
|
+
error.stderr = stderr;
|
|
87
|
+
reject(error);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
resolve({ stdout, stderr });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 探测单个目录。
|
|
97
|
+
* 返回 isGitRepo:true|false|null —— null 表示"没探到"(超时/权限等),
|
|
98
|
+
* 前端对 null 不显示任何 Git 标记,避免把未知谎报成"非仓库"。
|
|
99
|
+
*/
|
|
100
|
+
export async function probeDirectoryGitState(dirPath, { timeoutMs = DEFAULT_PROBE_TIMEOUT_MS } = {}) {
|
|
101
|
+
try {
|
|
102
|
+
const stat = await fs.stat(dirPath);
|
|
103
|
+
if (!stat.isDirectory()) return emptyState({ exists: false });
|
|
104
|
+
} catch {
|
|
105
|
+
return emptyState({ exists: false });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const { stdout } = await execFileAsync(
|
|
110
|
+
'git',
|
|
111
|
+
[
|
|
112
|
+
'-C', dirPath,
|
|
113
|
+
// --no-optional-locks:纯读探测不抢 index.lock,避免和用户正在跑的 git 操作打架
|
|
114
|
+
'--no-optional-locks',
|
|
115
|
+
'status',
|
|
116
|
+
'--porcelain=v1',
|
|
117
|
+
// normal 会把未跟踪目录折叠成一条,大目录(node_modules 之类)下比 all 快很多,
|
|
118
|
+
// 而这里只需要"有没有 / 有几项"
|
|
119
|
+
'--untracked-files=normal',
|
|
120
|
+
],
|
|
121
|
+
{
|
|
122
|
+
timeout: timeoutMs,
|
|
123
|
+
maxBuffer: MAX_BUFFER,
|
|
124
|
+
windowsHide: true,
|
|
125
|
+
env: {
|
|
126
|
+
...process.env,
|
|
127
|
+
// 与 execGitCommand 保持一致:关闭路径转义,避免中文/空格路径变成 \xxx 八进制
|
|
128
|
+
GIT_CONFIG_PARAMETERS: "'core.quotepath=false'",
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
return { exists: true, isGitRepo: true, ...parsePorcelainStatus(stdout) };
|
|
133
|
+
} catch (error) {
|
|
134
|
+
const text = `${error?.stderr || ''}\n${error?.message || ''}`;
|
|
135
|
+
if (/not a git repository|not a git repo/i.test(text)) {
|
|
136
|
+
// 目录存在但不是仓库:明确回答"不是",不算错误
|
|
137
|
+
return emptyState();
|
|
138
|
+
}
|
|
139
|
+
// 超时 / 权限 / git 不存在等 —— 状态未知
|
|
140
|
+
return emptyState({ isGitRepo: null, error: String(error?.message || error).split('\n')[0] });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 以固定并发跑完所有任务,保持入参顺序输出 */
|
|
145
|
+
async function runWithConcurrency(items, limit, worker) {
|
|
146
|
+
const results = new Array(items.length);
|
|
147
|
+
let cursor = 0;
|
|
148
|
+
const size = Math.max(1, Math.min(limit, items.length));
|
|
149
|
+
await Promise.all(Array.from({ length: size }, async () => {
|
|
150
|
+
while (cursor < items.length) {
|
|
151
|
+
const index = cursor;
|
|
152
|
+
cursor += 1;
|
|
153
|
+
results[index] = await worker(items[index], index);
|
|
154
|
+
}
|
|
155
|
+
}));
|
|
156
|
+
return results;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 批量探测。返回 `{ [原始路径]: state }` —— 键用调用方传入的原始字符串,
|
|
161
|
+
* 前端可以直接 `states[item.path]` 取,不必关心后端的归一化规则。
|
|
162
|
+
*
|
|
163
|
+
* @param {string[]} paths
|
|
164
|
+
* @param {{ timeoutMs?: number, ttlMs?: number, concurrency?: number, useCache?: boolean }} [options]
|
|
165
|
+
*/
|
|
166
|
+
export async function probeDirectoryGitStates(paths, options = {}) {
|
|
167
|
+
const {
|
|
168
|
+
timeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
|
|
169
|
+
ttlMs = DEFAULT_CACHE_TTL_MS,
|
|
170
|
+
concurrency = DEFAULT_CONCURRENCY,
|
|
171
|
+
useCache = true,
|
|
172
|
+
} = options;
|
|
173
|
+
|
|
174
|
+
const list = Array.isArray(paths) ? paths.filter(p => typeof p === 'string' && p.trim()) : [];
|
|
175
|
+
const results = {};
|
|
176
|
+
const pending = [];
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
|
|
179
|
+
for (const original of list) {
|
|
180
|
+
if (Object.prototype.hasOwnProperty.call(results, original)) continue; // 重复路径只探一次
|
|
181
|
+
const key = normalizeDirKey(original);
|
|
182
|
+
const hit = useCache ? cache.get(key) : undefined;
|
|
183
|
+
if (hit && now - hit.ts < ttlMs) {
|
|
184
|
+
results[original] = hit.state;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
pending.push({ key, original });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const probed = await runWithConcurrency(pending, concurrency, async ({ key, original }) => {
|
|
191
|
+
const state = await probeDirectoryGitState(original, { timeoutMs });
|
|
192
|
+
return { key, original, state };
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
for (const { key, original, state } of probed) {
|
|
196
|
+
if (state.isGitRepo !== null) {
|
|
197
|
+
// 只有确定的结果才缓存:超时/未知下次还要再试
|
|
198
|
+
cache.set(key, { ts: Date.now(), state });
|
|
199
|
+
}
|
|
200
|
+
results[original] = state;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return results;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 仅供测试:清空缓存 */
|
|
207
|
+
export function clearGitStateCache() {
|
|
208
|
+
cache.clear();
|
|
209
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// 目录 Git 状态探测 单元测试(node:test 内置)
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import fs from 'node:fs/promises';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { execFile } from 'node:child_process';
|
|
22
|
+
import { promisify } from 'node:util';
|
|
23
|
+
import {
|
|
24
|
+
parsePorcelainStatus,
|
|
25
|
+
normalizeDirKey,
|
|
26
|
+
probeDirectoryGitState,
|
|
27
|
+
probeDirectoryGitStates,
|
|
28
|
+
clearGitStateCache,
|
|
29
|
+
} from './directoryGitState.js';
|
|
30
|
+
|
|
31
|
+
const execFileAsync = promisify(execFile);
|
|
32
|
+
const git = (args, cwd) => execFileAsync('git', args, { cwd, windowsHide: true });
|
|
33
|
+
|
|
34
|
+
// ── 纯函数:porcelain 解析 ────────────────────────────────────────────────
|
|
35
|
+
test('parsePorcelainStatus: 空输入返回全零', () => {
|
|
36
|
+
assert.deepEqual(parsePorcelainStatus(''), { changed: 0, staged: 0, unstaged: 0, untracked: 0 });
|
|
37
|
+
assert.deepEqual(parsePorcelainStatus(undefined), { changed: 0, staged: 0, unstaged: 0, untracked: 0 });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('parsePorcelainStatus: 分别统计已暂存 / 未暂存 / 未跟踪', () => {
|
|
41
|
+
const out = [
|
|
42
|
+
' M src/a.js', // 工作区改动
|
|
43
|
+
'M src/b.js', // 已暂存
|
|
44
|
+
'?? notes.txt', // 未跟踪
|
|
45
|
+
'A src/c.js', // 新文件已暂存
|
|
46
|
+
].join('\n');
|
|
47
|
+
const r = parsePorcelainStatus(out);
|
|
48
|
+
assert.equal(r.staged, 2);
|
|
49
|
+
assert.equal(r.unstaged, 1);
|
|
50
|
+
assert.equal(r.untracked, 1);
|
|
51
|
+
assert.equal(r.changed, 4);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('parsePorcelainStatus: 同一文件既暂存又改动(MM)只算 1 项,但两边各计一次', () => {
|
|
55
|
+
const r = parsePorcelainStatus('MM src/both.js');
|
|
56
|
+
assert.equal(r.changed, 1, 'changed 按条目数,不能重复计数');
|
|
57
|
+
assert.equal(r.staged, 1);
|
|
58
|
+
assert.equal(r.unstaged, 1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('parsePorcelainStatus: 冲突(UU)两侧都计数,条目仍为 1', () => {
|
|
62
|
+
const r = parsePorcelainStatus('UU src/conflict.js');
|
|
63
|
+
assert.equal(r.changed, 1);
|
|
64
|
+
assert.equal(r.staged, 1);
|
|
65
|
+
assert.equal(r.unstaged, 1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('parsePorcelainStatus: 重命名算 1 项(已暂存)', () => {
|
|
69
|
+
const r = parsePorcelainStatus('R old.js -> new.js');
|
|
70
|
+
assert.equal(r.changed, 1);
|
|
71
|
+
assert.equal(r.staged, 1);
|
|
72
|
+
assert.equal(r.unstaged, 0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('parsePorcelainStatus: 忽略条目(!!)与 CRLF / 空行不计数', () => {
|
|
76
|
+
const out = '!! dist/bundle.js\r\n\r\n M a.js\r\n';
|
|
77
|
+
const r = parsePorcelainStatus(out);
|
|
78
|
+
assert.equal(r.changed, 1);
|
|
79
|
+
assert.equal(r.unstaged, 1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('normalizeDirKey: Windows 大小写 / 斜杠 / 尾斜杠写法归一', () => {
|
|
83
|
+
const a = normalizeDirKey('D:\\Workspace\\Proj\\');
|
|
84
|
+
const b = normalizeDirKey('d:/workspace/proj');
|
|
85
|
+
assert.equal(a, b);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ── 真实文件系统 + git 进程 ────────────────────────────────────────────────
|
|
89
|
+
test('probeDirectoryGitState: 不存在的目录 → exists=false', async () => {
|
|
90
|
+
const missing = path.join(os.tmpdir(), 'zen-gitsync-not-exist-' + Date.now());
|
|
91
|
+
const state = await probeDirectoryGitState(missing);
|
|
92
|
+
assert.equal(state.exists, false);
|
|
93
|
+
assert.equal(state.isGitRepo, false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('probeDirectoryGitState: 普通目录 → exists=true 且 isGitRepo=false', async () => {
|
|
97
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-plain-'));
|
|
98
|
+
try {
|
|
99
|
+
const state = await probeDirectoryGitState(dir);
|
|
100
|
+
assert.equal(state.exists, true);
|
|
101
|
+
assert.equal(state.isGitRepo, false);
|
|
102
|
+
assert.equal(state.changed, 0);
|
|
103
|
+
} finally {
|
|
104
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('probeDirectoryGitState: 真实仓库 → 干净 0 项,加文件后计数递增', async () => {
|
|
109
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-repo-'));
|
|
110
|
+
try {
|
|
111
|
+
await git(['init', '-q'], dir);
|
|
112
|
+
const clean = await probeDirectoryGitState(dir);
|
|
113
|
+
assert.equal(clean.isGitRepo, true);
|
|
114
|
+
assert.equal(clean.changed, 0);
|
|
115
|
+
|
|
116
|
+
await fs.writeFile(path.join(dir, 'new-file.txt'), 'hello\n');
|
|
117
|
+
const untracked = await probeDirectoryGitState(dir);
|
|
118
|
+
assert.equal(untracked.isGitRepo, true);
|
|
119
|
+
assert.equal(untracked.changed, 1);
|
|
120
|
+
assert.equal(untracked.untracked, 1);
|
|
121
|
+
|
|
122
|
+
await git(['add', 'new-file.txt'], dir);
|
|
123
|
+
const staged = await probeDirectoryGitState(dir);
|
|
124
|
+
assert.equal(staged.staged, 1);
|
|
125
|
+
assert.equal(staged.changed, 1);
|
|
126
|
+
} finally {
|
|
127
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('probeDirectoryGitStates: 结果按调用方原始路径字符串为键,重复路径只探一次', async () => {
|
|
132
|
+
clearGitStateCache();
|
|
133
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-batch-'));
|
|
134
|
+
try {
|
|
135
|
+
await git(['init', '-q'], dir);
|
|
136
|
+
const results = await probeDirectoryGitStates([dir, dir, 'not-a-real-path-xyz'], { useCache: false });
|
|
137
|
+
assert.deepEqual(Object.keys(results).sort(), [dir, 'not-a-real-path-xyz'].sort());
|
|
138
|
+
assert.equal(results[dir].isGitRepo, true);
|
|
139
|
+
assert.equal(results['not-a-real-path-xyz'].exists, false);
|
|
140
|
+
} finally {
|
|
141
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('probeDirectoryGitStates: 命中缓存时不重新扫盘,useCache:false 可绕过', async () => {
|
|
146
|
+
clearGitStateCache();
|
|
147
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-cache-'));
|
|
148
|
+
try {
|
|
149
|
+
await git(['init', '-q'], dir);
|
|
150
|
+
const first = await probeDirectoryGitStates([dir]);
|
|
151
|
+
assert.equal(first[dir].changed, 0);
|
|
152
|
+
|
|
153
|
+
// 缓存期内新增文件,结果应仍是缓存的旧值
|
|
154
|
+
await fs.writeFile(path.join(dir, 'later.txt'), 'x\n');
|
|
155
|
+
const cached = await probeDirectoryGitStates([dir]);
|
|
156
|
+
assert.equal(cached[dir].changed, 0, 'TTL 内应命中缓存');
|
|
157
|
+
|
|
158
|
+
const fresh = await probeDirectoryGitStates([dir], { useCache: false });
|
|
159
|
+
assert.equal(fresh[dir].changed, 1, 'useCache:false 必须重新探测');
|
|
160
|
+
} finally {
|
|
161
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
});
|