difftab 0.1.3 → 0.2.1
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/README.md +5 -2
- package/README.zh-CN.md +2 -1
- package/bin/difftab.js +18 -26
- package/dist/server/main.js +757 -689
- package/dist/web/app.css +1 -1
- package/dist/web/app.js +33 -29
- package/package.json +2 -2
package/dist/server/main.js
CHANGED
|
@@ -5,7 +5,7 @@ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { createServer, request } from "node:http";
|
|
7
7
|
import { homedir, tmpdir } from "node:os";
|
|
8
|
-
import { access, lstat, readFile, readlink } from "node:fs/promises";
|
|
8
|
+
import { access, lstat, readFile, readdir, readlink, realpath } from "node:fs/promises";
|
|
9
9
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
10
10
|
|
|
11
11
|
//#region src/server/cli/args.ts
|
|
@@ -53,37 +53,28 @@ function parseCliArgs(argv) {
|
|
|
53
53
|
//#endregion
|
|
54
54
|
//#region src/server/git/run.ts
|
|
55
55
|
/**
|
|
56
|
-
* 所有 git
|
|
56
|
+
* 所有 git 调用统一注入,不留给各调用点自己记得加。`-z` 只作用于 status / numstat 这类
|
|
57
|
+
* **列表输出**,管不到 `git diff` 的补丁正文——正文里的 `diff --git` / `---` / `+++` /
|
|
58
|
+
* `rename from|to` 头部行仍按 C 风格转义,而 diff2html 恰恰从这些头部行解析文件名。
|
|
57
59
|
*
|
|
58
|
-
*
|
|
59
|
-
* 正文里的 `diff --git` / `---` / `+++` / `rename from|to` 头部行仍会按 C 风格转义
|
|
60
|
-
* (已实测),而 diff2html 恰恰是从这些头部行解析文件名的。两者互补,不可相互替代。
|
|
61
|
-
*
|
|
62
|
-
* 注入位置必须在子命令**之前**:`-c` 是 git 的全局选项,放在子命令后面 git 不认。
|
|
60
|
+
* 注入位置必须在子命令**之前**:`-c` 是 git 的全局选项,放在子命令后面 git 不认。
|
|
63
61
|
*/
|
|
64
62
|
const GLOBAL_CONFIG = ["-c", "core.quotePath=false"];
|
|
65
63
|
/**
|
|
66
64
|
* 子进程环境。
|
|
67
65
|
*
|
|
68
|
-
* `GIT_OPTIONAL_LOCKS=0`
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* 回写当 best-effort,`.git` 不可写时它静默跳过、exit 0、stderr 全空。真正看得见
|
|
74
|
-
* 的是第二层的 **B 半**——在可写的 `.git` 上前后做逐字节快照比对
|
|
75
|
-
* (`test/smoke/readonly-git-dir.test.js`)。别把 B 半当成 A 半的重复给删了。
|
|
76
|
-
*
|
|
77
|
-
* 该变量在 git < 2.15 上不存在,设了也无害 —— 那个区间的 git 只是照旧写 index。
|
|
66
|
+
* `GIT_OPTIONAL_LOCKS=0` 是只读承诺的一部分而不是性能开关:默认情况下 `git status` 会顺手
|
|
67
|
+
* 把刷新过的 stat 缓存写回 `.git/index`。那不改变 status 的输出,「前后 `git status` 比对」
|
|
68
|
+
* 这类验证因此发现不了它。**把这一行删掉,只读 `.git` 那半层门禁照样全绿**——git 把
|
|
69
|
+
* index 回写当 best-effort,`.git` 不可写时静默跳过、exit 0、stderr 全空;真正看得见的是
|
|
70
|
+
* 第二层的 **B 半**(可写 `.git` 上的逐字节快照比对),别把它当成 A 半的重复给删了。
|
|
78
71
|
*
|
|
79
72
|
* `GIT_TERMINAL_PROMPT=0` 防止任何意外的凭据交互把无人值守的进程挂住。
|
|
80
73
|
*
|
|
81
|
-
* **`GIT_LITERAL_PATHSPECS=1`
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* 匹配到别人身上,页面在 A 的标题下显示 B 的补丁。设了它,`*` / `?` / `[…]` 一律
|
|
86
|
-
* 按字面比较。本项目的路径无一例外来自 git 自己的输出,不需要任何通配语义。
|
|
74
|
+
* **`GIT_LITERAL_PATHSPECS=1` 是安全项而不是洁癖**:`--` 后面的路径默认是 wildmatch
|
|
75
|
+
* 模式,而我们的路径全部来自 URL query——`path=*` 会让 `git diff HEAD -- '*'` 回一份
|
|
76
|
+
* **整仓 diff**,正是红线明令禁止、会把浏览器主线程冻上数十秒的那件事;而一个真实存在、
|
|
77
|
+
* 名字里带 `*` 的文件同样会匹配到别人身上,页面在 A 的标题下显示 B 的补丁。
|
|
87
78
|
*/
|
|
88
79
|
const GIT_ENV = {
|
|
89
80
|
GIT_OPTIONAL_LOCKS: "0",
|
|
@@ -91,12 +82,9 @@ const GIT_ENV = {
|
|
|
91
82
|
GIT_LITERAL_PATHSPECS: "1"
|
|
92
83
|
};
|
|
93
84
|
/**
|
|
94
|
-
* stdout
|
|
95
|
-
*
|
|
96
|
-
|
|
97
|
-
* 产品语义那道 5MB 闸不在这里,而是由调用方按次传 `maxStdoutBytes`(
|
|
98
|
-
* 「卡补丁字节数」——见 git/diff.ts):**能不能渲染取决于补丁多大,而不是文件多大**,
|
|
99
|
-
* 一个 6MB 的数据文件改一行照样该看得见。本常量只管「别把进程撑爆」。
|
|
85
|
+
* stdout 的**兜底**上限:没有它,一个几百 MB 的文件的 diff 会被整个读进内存,而这条路径
|
|
86
|
+
* 上没有任何东西会先失败。产品语义那道 5MB 闸不在这里,由调用方按次传 `maxStdoutBytes`
|
|
87
|
+
*——**能不能渲染取决于补丁多大,而不是文件多大**,一个 6MB 的数据文件改一行照样该看得见。
|
|
100
88
|
*/
|
|
101
89
|
const MAX_STDOUT_BYTES = 67108864;
|
|
102
90
|
var GitError = class extends Error {
|
|
@@ -114,9 +102,8 @@ var GitError = class extends Error {
|
|
|
114
102
|
}
|
|
115
103
|
};
|
|
116
104
|
/**
|
|
117
|
-
* 跑一条 git
|
|
118
|
-
*
|
|
119
|
-
* 正是靠非零退出来给出答案的。
|
|
105
|
+
* 跑一条 git 命令。**非零退出不抛异常**——若干只读探测(空仓库下的
|
|
106
|
+
* `rev-parse --verify HEAD`、下限之下的 `--show-object-format`)正是靠非零退出给答案的。
|
|
120
107
|
*/
|
|
121
108
|
function runGit(args, cwd, options = {}) {
|
|
122
109
|
const limit = Math.min(options.maxStdoutBytes ?? MAX_STDOUT_BYTES, MAX_STDOUT_BYTES);
|
|
@@ -151,17 +138,13 @@ function runGit(args, cwd, options = {}) {
|
|
|
151
138
|
err.push(chunk);
|
|
152
139
|
});
|
|
153
140
|
/**
|
|
154
|
-
* **`kill()` 之后到达的报错是这次 kill
|
|
141
|
+
* **`kill()` 之后到达的报错是这次 kill 的后果,不是新事实。**
|
|
155
142
|
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
* 所以只要已经判定超限,一律以 `overflow` 收尾;流上的 `'error'` 同样咽掉 ——
|
|
163
|
-
* 一个没人监听的流错误会以未捕获异常掀掉整个服务,而进程的死活由下面两个
|
|
164
|
-
* 事件负责报告,不需要它再说一遍。
|
|
143
|
+
* 实测里 windows × Node 22.0.x 那一档会在超限掐断 git 之后走到下面的 `'error'` 分支,
|
|
144
|
+
* 于是 `kind` 成了 `exit`,调用方认不出「补丁太大」,`too-large` 那条分支静默变成一个
|
|
145
|
+
* 500。报的是 EPIPE(git 还写在管道上)还是 kill 本身失败无从也无需区分:判定超限
|
|
146
|
+
* 之后,任何错误都是我们自己动手的结果,一律以 `overflow` 收尾。流上的 `'error'` 同样
|
|
147
|
+
* 咽掉——一个没人监听的流错误会以未捕获异常掀掉整个服务。
|
|
165
148
|
*/
|
|
166
149
|
const settleOverflow = () => rejectPromise(new GitError("overflow", argv, "", null));
|
|
167
150
|
for (const stream of [child.stdout, child.stderr]) stream.on("error", () => {
|
|
@@ -188,7 +171,7 @@ function runGit(args, cwd, options = {}) {
|
|
|
188
171
|
});
|
|
189
172
|
});
|
|
190
173
|
}
|
|
191
|
-
/**
|
|
174
|
+
/** 同上,但非零退出即抛。用于「失败没有第二种解释」的调用点。 */
|
|
192
175
|
async function runGitStrict(args, cwd) {
|
|
193
176
|
const result = await runGit(args, cwd);
|
|
194
177
|
if (result.code !== 0) throw new GitError("exit", args, result.stderr, result.code);
|
|
@@ -203,17 +186,15 @@ const MIN_GIT = {
|
|
|
203
186
|
minor: 11
|
|
204
187
|
};
|
|
205
188
|
/**
|
|
206
|
-
* 空树对象哈希。空仓库下 HEAD 不存在、`git diff HEAD` 直接 fatal
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
* 硬编码是**要求**而不是偷懒:`git hash-object -t tree /dev/null` 依赖 `/dev/null`,
|
|
210
|
-
* Windows 上不可移植;`git mktree` 会写对象库,直接违反只读承诺。
|
|
189
|
+
* 空树对象哈希。空仓库下 HEAD 不存在、`git diff HEAD` 直接 fatal,改用它作 diff 基准即可。
|
|
190
|
+
* 硬编码是**要求**而不是偷懒:`git hash-object -t tree /dev/null` 依赖 `/dev/null`,
|
|
191
|
+
* Windows 上不可移植;`git mktree` 会写对象库,直接违反只读承诺。
|
|
211
192
|
*/
|
|
212
193
|
const EMPTY_TREE = {
|
|
213
194
|
sha1: "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
|
|
214
195
|
sha256: "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321"
|
|
215
196
|
};
|
|
216
|
-
/** 启动前置检查失败。CLI
|
|
197
|
+
/** 启动前置检查失败。CLI 据此打印一句话友好报错,而不是抛 Node 异常栈。 */
|
|
217
198
|
var PreflightError = class extends Error {
|
|
218
199
|
code;
|
|
219
200
|
constructor(code, message) {
|
|
@@ -235,11 +216,8 @@ function tooOld(v) {
|
|
|
235
216
|
return v.major < MIN_GIT.major || v.major === MIN_GIT.major && v.minor < MIN_GIT.minor;
|
|
236
217
|
}
|
|
237
218
|
/**
|
|
238
|
-
* 前置检查 +
|
|
239
|
-
*
|
|
240
|
-
* 两条命令并发跑:串行等于把两次进程启动开销叠加进 300ms 冷启动预算,
|
|
241
|
-
* 而它们之间没有依赖。失败路径上的额外一次 `rev-parse` 不计成本 —— 那条路径的
|
|
242
|
-
* 终点是打印一行报错然后退出。
|
|
219
|
+
* 前置检查 + 定位,一次做完。两条命令并发跑:串行等于把两次进程启动开销叠加进 300ms
|
|
220
|
+
* 冷启动预算,而它们之间没有依赖。
|
|
243
221
|
*/
|
|
244
222
|
async function locateRepo(cwd) {
|
|
245
223
|
const [version, located] = await Promise.all([runGit(["--version"], cwd), runGit([
|
|
@@ -267,30 +245,23 @@ async function locateRepo(cwd) {
|
|
|
267
245
|
};
|
|
268
246
|
}
|
|
269
247
|
/**
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* **和 `root` 住在一起,不在消费者那边现切**:下面那条「用 `node:path` 不手写切分」
|
|
273
|
-
* 的理由整个建立在 `root` 是怎么来的之上(`rev-parse --show-toplevel`),而那件事只有
|
|
274
|
-
* 本文件知道。放在 `http/` 里的话,这里改一次 `root` 的产出方式,那边的论证就静默过期。
|
|
248
|
+
* 工作区根目录名——页面标题里的项目标识(`RepoState.repoName`)。
|
|
275
249
|
*
|
|
276
|
-
*
|
|
277
|
-
* `
|
|
278
|
-
* posix 的 basename 不会误把它当分隔符。两个平台各有一半是手写切分挑不对的。
|
|
250
|
+
* **和 `root` 住在一起,不在消费者那边现切**:下面那条「用 `node:path` 不手写切分」的理由
|
|
251
|
+
* 整个建立在 `root` 是怎么来的之上(`rev-parse --show-toplevel`),而那件事只有本文件知道。
|
|
279
252
|
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
253
|
+
* **用 `node:path` 的 `basename`,不要自己切 `/`**:Windows 上 `--show-toplevel` 回的是
|
|
254
|
+
* `C:/…` 正斜杠,而 win32 的 basename 两种分隔符都认;POSIX 上 `\` 是合法文件名字符,
|
|
255
|
+
* posix 的 basename 不会误把它当分隔符。根目录没有 basename 时回空串,**不编一个名字
|
|
256
|
+
* 出来**——「取不到时显示什么」是展示决定,归消费者。
|
|
282
257
|
*/
|
|
283
258
|
function repoNameOf(repo) {
|
|
284
259
|
return basename(repo.root);
|
|
285
260
|
}
|
|
286
261
|
/**
|
|
287
|
-
* diff
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
* 降级为空树哈希。
|
|
291
|
-
*
|
|
292
|
-
* 不做缓存:`git checkout --orphan` 之后 HEAD 会重新变回未出生状态,缓存的正结果
|
|
293
|
-
* 会让 diff 从此全部 fatal。这条只在取 diff 时调用,不落在冷启动路径上。
|
|
262
|
+
* diff 的基准。正常仓库是 `HEAD`;空仓库(尚无任何提交)下 HEAD 不存在、`git diff HEAD`
|
|
263
|
+
* 会 fatal,降级为空树哈希。不做缓存:`git checkout --orphan` 之后 HEAD 会重新变回未出生
|
|
264
|
+
* 状态,缓存的正结果会让 diff 从此全部 fatal。
|
|
294
265
|
*/
|
|
295
266
|
async function resolveDiffBase(root) {
|
|
296
267
|
const head = await runGit([
|
|
@@ -305,44 +276,136 @@ async function resolveDiffBase(root) {
|
|
|
305
276
|
}
|
|
306
277
|
|
|
307
278
|
//#endregion
|
|
308
|
-
//#region src/server/git/
|
|
279
|
+
//#region src/server/git/worktree.ts
|
|
309
280
|
/**
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
* 就是补丁,所以量文件(还省得把它读进来);已跟踪那一侧补丁只含改动与上下文,
|
|
314
|
-
* 所以量补丁本身(见 `trackedDiff`)。
|
|
281
|
+
* 超过这个字节数只提示、不预览。**两条路量的东西不同,但量的都是「前端要吃下多少
|
|
282
|
+
* 字节」**:读磁盘那一侧整份文件就是要发的东西(量文件还省得把它读进来),已跟踪那一侧
|
|
283
|
+
* 补丁只含改动与上下文,所以量补丁本身(见 `diff.ts` 的 `trackedDiff`)。
|
|
315
284
|
*/
|
|
316
285
|
const MAX_BYTES = 5242880;
|
|
317
286
|
/**
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
* 已跟踪那一侧数的是**改动行数**(numstat 的加 + 减),未跟踪数的是文件行数 ——
|
|
322
|
-
* 后者本来就整份都是新增行。两边量的都是「前端要渲染多少行」。
|
|
287
|
+
* 行数上限。体积阈值挡不住另一头:超长行数的窄文件体积不大,但逐行构造 diff 与前端渲染
|
|
288
|
+
* 同样会卡。已跟踪那一侧数的是**改动行数**(numstat 的加 + 减),读磁盘这一侧数文件行数。
|
|
323
289
|
*/
|
|
324
290
|
const MAX_LINES = 5e4;
|
|
325
|
-
|
|
291
|
+
/** 坏请求(路径非法 / 目标不在了)。**不叫 `DiffRequestError`**:三条路都会抛它。 */
|
|
292
|
+
var WorktreeError = class extends Error {
|
|
326
293
|
code;
|
|
327
294
|
constructor(code, message) {
|
|
328
295
|
super(message);
|
|
329
|
-
this.name = "
|
|
296
|
+
this.name = "WorktreeError";
|
|
330
297
|
this.code = code;
|
|
331
298
|
}
|
|
332
299
|
};
|
|
300
|
+
/** `target` 落在 `base` 里(含相等)。**边界谓词只此一份**——两份漂开时弱的那份就是一个穿越。 */
|
|
301
|
+
function contains(base, target) {
|
|
302
|
+
const rel = relative(base, target);
|
|
303
|
+
return rel === "" || !rel.startsWith("..") && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
304
|
+
}
|
|
333
305
|
/**
|
|
334
|
-
*
|
|
306
|
+
* 把请求里的路径落到磁盘上,**两道边界一次过完**。
|
|
307
|
+
*
|
|
308
|
+
* 1. **字面量那道**:拒绝绝对路径、含 `\0` 的、以及 `relative()` 走出去的那些。
|
|
309
|
+
* 2. **`realpath` 那道**:把中间段全部展开成真实路径再比一次。**只有第一道时 `lstat` 给的
|
|
310
|
+
* 保护是假的——它只管最后一段**:仓库里有个 `linkdir -> ../outside` 时,
|
|
311
|
+
* `linkdir/secret.txt` 在字面上老实待在仓库内,而 `readFile` 顺着它走出去(已实测)。
|
|
335
312
|
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
313
|
+
* **一个 gate、用途由参数说明,而不是按用途分叉成几个函数**:分叉时挑错哪一个不报错,正好
|
|
314
|
+
* 复现上面那个洞。返回值里的 `path` 是**归一化**的——调用方据此做前缀匹配与 pathspec,于是
|
|
315
|
+
* `dir/`、`./dir`、`dir//x` 这些拼法不必逐个特判(`resolve()` 本来就把它们归一了,先前那个
|
|
316
|
+
* 只取 `abs` 的写法把这份结果扔掉了,才不得不在外面补一条 `endsWith('/')`)。
|
|
338
317
|
*/
|
|
339
|
-
function resolveInRepo(root, path) {
|
|
340
|
-
if (
|
|
341
|
-
|
|
318
|
+
async function resolveInRepo(root, path, options) {
|
|
319
|
+
if (path.includes("\0") || isAbsolute(path)) throw new WorktreeError("invalid-path", "invalid path");
|
|
320
|
+
if (path === "") {
|
|
321
|
+
if (options.allowRoot !== true) throw new WorktreeError("invalid-path", "invalid path");
|
|
322
|
+
}
|
|
323
|
+
const abs = path === "" ? resolve(root) : resolve(root, path);
|
|
342
324
|
const rel = relative(root, abs);
|
|
343
|
-
if (
|
|
344
|
-
|
|
325
|
+
if (!contains(root, abs) || rel === "" && options.allowRoot !== true) throw new WorktreeError("invalid-path", "invalid path");
|
|
326
|
+
const realRoot = await realpath(root);
|
|
327
|
+
if (options.follow) {
|
|
328
|
+
let real;
|
|
329
|
+
try {
|
|
330
|
+
real = await realpath(abs);
|
|
331
|
+
} catch {
|
|
332
|
+
throw new WorktreeError("not-found", "no longer exists");
|
|
333
|
+
}
|
|
334
|
+
if (!contains(realRoot, real)) throw new WorktreeError("invalid-path", "invalid path");
|
|
335
|
+
} else {
|
|
336
|
+
let realParent;
|
|
337
|
+
try {
|
|
338
|
+
realParent = await realpath(resolve(abs, ".."));
|
|
339
|
+
} catch {
|
|
340
|
+
throw new WorktreeError("not-found", "file no longer exists");
|
|
341
|
+
}
|
|
342
|
+
if (!contains(realRoot, realParent)) throw new WorktreeError("invalid-path", "invalid path");
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
abs,
|
|
346
|
+
path: rel.split(sep).join("/")
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* 数行数。**空文件是 0 行,末尾那个换行不另算一行**——这是全仓唯一一份行数口径,diff 与文件
|
|
351
|
+
* 视图共用它;两份漂开的症状是一个正好 50,000 行的文件在一个面板里能看、在另一个里说太大。
|
|
352
|
+
*
|
|
353
|
+
* 在 **Buffer 上数**而不是 `split('\n').length`:后者为了得到一个数字,把整份文件又切成 N 个
|
|
354
|
+
* 字符串立刻丢掉,而超限那一路根本不需要正文。
|
|
355
|
+
*/
|
|
356
|
+
function countLines(buffer) {
|
|
357
|
+
if (buffer.length === 0) return 0;
|
|
358
|
+
let lines = 0;
|
|
359
|
+
for (let i = buffer.indexOf(10); i !== -1; i = buffer.indexOf(10, i + 1)) lines += 1;
|
|
360
|
+
return buffer[buffer.length - 1] === 10 ? lines : lines + 1;
|
|
345
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* 读一个工作区文件并分类。顺序是固定的:符号链接 → 非普通文件 → 体积 → 二进制 → 行数。
|
|
364
|
+
*
|
|
365
|
+
* - **`lstat` 而不是 `stat`**:未跟踪的符号链接照样进变更列表、也照样出现在目录树里,而
|
|
366
|
+
* `stat` 跟随链接——校验过的是**链接自身**的路径,读到的却是链接目标。给的因此是**链接
|
|
367
|
+
* 目标字符串本身**,与 git 对 mode 120000 的处理一致。
|
|
368
|
+
* - **二进制走 NUL 字节探测**:这条路上没有 numstat 可依(未跟踪与被忽略的文件 git 根本不
|
|
369
|
+
* 比对),而 git 自己那套含 `.gitattributes` 的判定只对已跟踪文件给得出。
|
|
370
|
+
* - **二进制排在行数之前**:它比「太大」更具体,一个 8MB 的 PNG 说「文件过大」等于把原因说错。
|
|
371
|
+
*/
|
|
372
|
+
async function inspectFile(root, path) {
|
|
373
|
+
const { abs } = await resolveInRepo(root, path, { follow: false });
|
|
374
|
+
let info;
|
|
375
|
+
try {
|
|
376
|
+
info = await lstat(abs);
|
|
377
|
+
} catch {
|
|
378
|
+
throw new WorktreeError("not-found", "file no longer exists");
|
|
379
|
+
}
|
|
380
|
+
if (info.isSymbolicLink()) return {
|
|
381
|
+
kind: "symlink",
|
|
382
|
+
target: await readlink(abs)
|
|
383
|
+
};
|
|
384
|
+
if (!info.isFile()) throw new WorktreeError("invalid-path", "not a regular file");
|
|
385
|
+
if (info.size > 5242880) return {
|
|
386
|
+
kind: "too-large",
|
|
387
|
+
size: info.size,
|
|
388
|
+
reason: "size"
|
|
389
|
+
};
|
|
390
|
+
const buffer = await readFile(abs);
|
|
391
|
+
if (buffer.includes(0)) return { kind: "binary" };
|
|
392
|
+
const lines = countLines(buffer);
|
|
393
|
+
if (lines > 5e4) return {
|
|
394
|
+
kind: "too-large",
|
|
395
|
+
size: info.size,
|
|
396
|
+
reason: "lines"
|
|
397
|
+
};
|
|
398
|
+
return {
|
|
399
|
+
kind: "text",
|
|
400
|
+
buffer,
|
|
401
|
+
size: info.size,
|
|
402
|
+
lines,
|
|
403
|
+
executable: (info.mode & 73) !== 0
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
//#endregion
|
|
408
|
+
//#region src/server/git/diff.ts
|
|
346
409
|
/** `-z` 输出里是否原样出现这条路径。 */
|
|
347
410
|
function lists(output, path) {
|
|
348
411
|
return output.split("\0").some((entry) => entry === path);
|
|
@@ -357,15 +420,10 @@ async function inIndex(root, path) {
|
|
|
357
420
|
], root), path);
|
|
358
421
|
}
|
|
359
422
|
/**
|
|
360
|
-
* `git diff --numstat -z`
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
* 与 `status --porcelain=v2` 的 `2 ` 记录**顺序相反**(那边是新在前、旧在后)。
|
|
365
|
-
* 无状态地按 NUL 平铺切分,就会把 `<旧路径>` 当成下一条记录的开头。
|
|
366
|
-
*
|
|
367
|
-
* 本项目每次只问一条路径,所以「多吞两段」眼下不改变结果 —— 但把它写对的成本是
|
|
368
|
-
* 三行,而写错的症状是将来某天改成批量查询后,一半记录静默错位。
|
|
423
|
+
* `git diff --numstat -z` 的解析。**重命名记录不是一段而是三段**:普通记录是
|
|
424
|
+
* `<加>\t<减>\t<路径>`,占一个 NUL 段;重命名记录的**路径字段是空的**,后面紧跟
|
|
425
|
+
* `<旧路径>` `<新路径>` 两段——与 `status --porcelain=v2` 的 `2 ` 记录**顺序相反**
|
|
426
|
+
* (那边是新在前、旧在后)。无状态地按 NUL 平铺切分,就会把 `<旧路径>` 当成下一条记录。
|
|
369
427
|
*/
|
|
370
428
|
function parseNumstat(output) {
|
|
371
429
|
const segments = output.split("\0");
|
|
@@ -391,32 +449,29 @@ function parseNumstat(output) {
|
|
|
391
449
|
return records;
|
|
392
450
|
}
|
|
393
451
|
/**
|
|
394
|
-
* 「基准 → 工作区」这条路径上的 numstat
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
* 2. **是不是二进制**。已跟踪那一侧一律以 git 自己的判定为准(含 `.gitattributes`),
|
|
402
|
-
* 比 NUL 探测准确,形态是 `-\t-\t<路径>`;
|
|
452
|
+
* 「基准 → 工作区」这条路径上的 numstat 记录;不在差异里就是 `null`。这一次调用同时回答
|
|
453
|
+
* 三个问题,所以它取代了原先那次 `diff --name-only`:
|
|
454
|
+
* 1. **它是不是已跟踪的**。已跟踪是 **HEAD ∪ index**,而 `ls-files` 只答得出 index——
|
|
455
|
+
* `git rm` 之后已暂存的删除在 index 里已经没有了,status 却照样报 `1 D.`、基准侧也
|
|
456
|
+
* 还在。只认 index 会把它误判成未跟踪、掉进读磁盘那条路,以「文件不存在」告终;
|
|
457
|
+
* 2. **是不是二进制**。已跟踪那一侧一律以 git 自己的判定为准(含 `.gitattributes`),
|
|
458
|
+
* 形态是 `-\t-\t<路径>`;
|
|
403
459
|
* 3. **改了多少行**。行数阈值那一路的判据。
|
|
404
460
|
*
|
|
405
|
-
* 重命名同样要传两个路径 + `-M
|
|
406
|
-
*
|
|
461
|
+
* 重命名同样要传两个路径 + `-M`:只传新路径时 git 无法配对,numstat 会把它算成一个全
|
|
462
|
+
* 新增文件(行数因此是整份文件),与补丁那一侧的退化是同一回事。
|
|
407
463
|
*/
|
|
408
464
|
async function readNumstat(root, base, path, oldPath) {
|
|
409
465
|
/**
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
* 60,000 行的改名里,排在前面的是旧文件那条 100 行的删除,于是行数闸放它过去,
|
|
416
|
-
* 一份 6 万行的补丁照旧发给浏览器 —— 正是本阶段要防的那次冻结。二进制同理,
|
|
417
|
-
* 挑错记录会让一个文本文件被报成 binary(或反过来)。
|
|
466
|
+
* **一次查询可以回不止一条记录**,所以必须按路径挑、不能取 `[0]`:传了两个路径而 git
|
|
467
|
+
* **配不上对**时(`git mv` 之后把内容全部重写、留在工作区不 `git add`——status 照报
|
|
468
|
+
* `R100`,所以 `oldPath` 是有的),它会拆成「删掉旧的」+「新增新的」两条并按路径排序。
|
|
469
|
+
* 取 `[0]` 就是掷硬币:实测那条 60,000 行的改名里排在前面的是旧文件那条 100 行的删除,
|
|
470
|
+
* 于是行数闸放它过去,一份 6 万行的补丁照旧发给浏览器。二进制同理。
|
|
418
471
|
*
|
|
419
|
-
*
|
|
472
|
+
* 挑中之后**合并**而不是取一条:那份补丁里两个文件都在,前端要渲染的是两者之和。挑的是
|
|
473
|
+
* `path` 字段——git 的重命名检测发生在 pathspec 过滤**之后**,配上对与配不上对的记录
|
|
474
|
+
* 都落在我们给的这两个路径里,因此不必再比 `record.oldPath`。
|
|
420
475
|
*/
|
|
421
476
|
const mine = parseNumstat(await runGitStrict(oldPath ? [
|
|
422
477
|
"diff",
|
|
@@ -441,7 +496,7 @@ async function readNumstat(root, base, path, oldPath) {
|
|
|
441
496
|
lines: mine.reduce((total, record) => total + record.lines, 0)
|
|
442
497
|
};
|
|
443
498
|
}
|
|
444
|
-
/**
|
|
499
|
+
/** 工作区里这个文件的字节数;文件不在(删除)就是 `null`。 */
|
|
445
500
|
async function worktreeSize(abs) {
|
|
446
501
|
try {
|
|
447
502
|
return (await lstat(abs)).size;
|
|
@@ -450,29 +505,26 @@ async function worktreeSize(abs) {
|
|
|
450
505
|
}
|
|
451
506
|
}
|
|
452
507
|
/**
|
|
453
|
-
*
|
|
508
|
+
* 已跟踪文件的补丁,以及三道拒绝。
|
|
454
509
|
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
* 两者都量不到的东西正是字节,所以那一闸只能由取补丁那次调用自己带着上限去撞。
|
|
510
|
+
* **已跟踪那一侧卡的是「补丁多大」,不是「文件多大」**:按文件体积拒绝的话,一个 6MB 的
|
|
511
|
+
* 数据文件改一行就再也看不了,而那是 agent 最常见的输出之一。行数也**不能**替代——
|
|
512
|
+
* 一个「一行 6MB」的文件 numstat 只报 1 行。两者都量不到的东西正是字节,所以那一闸只能由
|
|
513
|
+
* 取补丁那次调用自己带着上限去撞。
|
|
460
514
|
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
515
|
+
* 顺序:二进制(numstat)→ 行数(numstat)→ 取补丁并卡字节。二进制先答是因为它比「太大」
|
|
516
|
+
* 更具体(一个 8MB 的 PNG 说「文件过大」等于把原因说错了);行数排在取补丁之前,是因为它
|
|
517
|
+
* 不用付出取补丁的代价就能拦下 5 万行以上的改动。
|
|
464
518
|
*
|
|
465
|
-
* `size`
|
|
466
|
-
*
|
|
467
|
-
* 取不到,给 0,前端据此不显示体积。
|
|
519
|
+
* `size` 只用于**说话**、不再参与判定,所以**只在要拒绝的那两条分支上才去 `lstat`**:
|
|
520
|
+
* 正常那条路一次系统调用都不欠。文件已被删除时取不到,给 0。
|
|
468
521
|
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
* 两个路径都来自 status 的 `2 ` 记录,无需额外查询。
|
|
522
|
+
* **重命名条目必须同时传新旧两个路径**:只传新路径时 git 看不到另一侧、无法配对,会把
|
|
523
|
+
* 重命名退化成一个全新增文件。两个路径都来自 status 的 `2 ` 记录,无需额外查询。
|
|
472
524
|
*/
|
|
473
525
|
async function trackedDiff(root, base, path, abs, oldPath, stat) {
|
|
474
526
|
if (stat?.binary) return { kind: "binary" };
|
|
475
|
-
if (stat && stat.lines >
|
|
527
|
+
if (stat && stat.lines > 5e4) return {
|
|
476
528
|
kind: "too-large",
|
|
477
529
|
size: await worktreeSize(abs) ?? 0,
|
|
478
530
|
reason: "lines"
|
|
@@ -501,13 +553,13 @@ async function trackedDiff(root, base, path, abs, oldPath, stat) {
|
|
|
501
553
|
};
|
|
502
554
|
throw cause;
|
|
503
555
|
}
|
|
504
|
-
if (result.code !== 0) throw new
|
|
556
|
+
if (result.code !== 0) throw new WorktreeError("not-found", "no diff available for this path");
|
|
505
557
|
return {
|
|
506
558
|
kind: "text",
|
|
507
559
|
patch: result.stdout
|
|
508
560
|
};
|
|
509
561
|
}
|
|
510
|
-
/** unified diff
|
|
562
|
+
/** unified diff 的头四行。未跟踪的一切都是「新增」,左侧恒为 /dev/null。 */
|
|
511
563
|
function newFileHeader(path, mode) {
|
|
512
564
|
return [
|
|
513
565
|
`diff --git a/${path} b/${path}`,
|
|
@@ -517,52 +569,33 @@ function newFileHeader(path, mode) {
|
|
|
517
569
|
];
|
|
518
570
|
}
|
|
519
571
|
/**
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
* 不用 `git diff --no-index` —— 它依赖 `/dev/null` 作为对比端,Windows 上不可移植。
|
|
572
|
+
* 未跟踪文件的补丁:**直接读文件内容手工构造 unified diff**。不用
|
|
573
|
+
* `git diff --no-index`——它依赖 `/dev/null` 作为对比端,Windows 上不可移植。
|
|
523
574
|
*/
|
|
524
575
|
async function untrackedDiff(root, path) {
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
info = await lstat(abs);
|
|
529
|
-
} catch {
|
|
530
|
-
throw new DiffRequestError("not-found", "file no longer exists");
|
|
531
|
-
}
|
|
532
|
-
if (info.isSymbolicLink()) {
|
|
533
|
-
const target = await readlink(abs);
|
|
534
|
-
return {
|
|
576
|
+
const file = await inspectFile(root, path);
|
|
577
|
+
switch (file.kind) {
|
|
578
|
+
case "symlink": return {
|
|
535
579
|
kind: "untracked-text",
|
|
536
580
|
patch: `${[
|
|
537
581
|
...newFileHeader(path, "120000"),
|
|
538
582
|
"@@ -0,0 +1 @@",
|
|
539
|
-
`+${target}`,
|
|
583
|
+
`+${file.target}`,
|
|
540
584
|
"\"
|
|
541
585
|
].join("\n")}\n`
|
|
542
586
|
};
|
|
587
|
+
case "binary":
|
|
588
|
+
case "too-large": return file;
|
|
543
589
|
}
|
|
544
|
-
|
|
545
|
-
if (
|
|
546
|
-
kind: "too-large",
|
|
547
|
-
size: info.size,
|
|
548
|
-
reason: "size"
|
|
549
|
-
};
|
|
550
|
-
const buffer = await readFile(abs);
|
|
551
|
-
if (buffer.includes(0)) return { kind: "binary" };
|
|
552
|
-
const text = buffer.toString("utf8");
|
|
553
|
-
const endsWithNewline = text.endsWith("\n");
|
|
554
|
-
const lines = text === "" ? [] : text.split("\n");
|
|
555
|
-
if (endsWithNewline) lines.pop();
|
|
556
|
-
if (lines.length > MAX_LINES) return {
|
|
557
|
-
kind: "too-large",
|
|
558
|
-
size: info.size,
|
|
559
|
-
reason: "lines"
|
|
560
|
-
};
|
|
561
|
-
const head = newFileHeader(path, info.mode & 73 ? "100755" : "100644");
|
|
562
|
-
if (lines.length === 0) return {
|
|
590
|
+
const head = newFileHeader(path, file.executable ? "100755" : "100644");
|
|
591
|
+
if (file.lines === 0) return {
|
|
563
592
|
kind: "untracked-text",
|
|
564
593
|
patch: `${head.join("\n")}\n`
|
|
565
594
|
};
|
|
595
|
+
const text = file.buffer.toString("utf8");
|
|
596
|
+
const endsWithNewline = text.endsWith("\n");
|
|
597
|
+
const lines = text.split("\n");
|
|
598
|
+
if (endsWithNewline) lines.pop();
|
|
566
599
|
const body = lines.map((line) => `+${line}`);
|
|
567
600
|
if (!endsWithNewline) body.push("\");
|
|
568
601
|
return {
|
|
@@ -575,28 +608,43 @@ async function untrackedDiff(root, path) {
|
|
|
575
608
|
};
|
|
576
609
|
}
|
|
577
610
|
async function readDiff(root, query) {
|
|
578
|
-
const abs = resolveInRepo(root, query.path);
|
|
579
|
-
if (query.oldPath) resolveInRepo(root, query.oldPath);
|
|
611
|
+
const { abs } = await resolveInRepo(root, query.path, { follow: false });
|
|
612
|
+
if (query.oldPath) await resolveInRepo(root, query.oldPath, { follow: false });
|
|
580
613
|
const [base, listed] = await Promise.all([resolveDiffBase(root), inIndex(root, query.path)]);
|
|
581
614
|
const stat = await readNumstat(root, base, query.path, query.oldPath);
|
|
582
615
|
if (listed || stat !== null) return trackedDiff(root, base, query.path, abs, query.oldPath, stat);
|
|
583
616
|
return untrackedDiff(root, query.path);
|
|
584
617
|
}
|
|
585
618
|
|
|
619
|
+
//#endregion
|
|
620
|
+
//#region src/server/git/file.ts
|
|
621
|
+
/** 读一个文件用于只读展示。 */
|
|
622
|
+
async function readFileContent(root, path) {
|
|
623
|
+
const file = await inspectFile(root, path);
|
|
624
|
+
switch (file.kind) {
|
|
625
|
+
case "symlink": return {
|
|
626
|
+
kind: "symlink",
|
|
627
|
+
target: file.target
|
|
628
|
+
};
|
|
629
|
+
case "binary":
|
|
630
|
+
case "too-large": return file;
|
|
631
|
+
case "text": return {
|
|
632
|
+
kind: "text",
|
|
633
|
+
content: file.buffer.toString("utf8")
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
586
638
|
//#endregion
|
|
587
639
|
//#region src/server/git/operation.ts
|
|
588
640
|
/**
|
|
589
|
-
*
|
|
641
|
+
* 判据表,**按序取第一个命中**。两处顺序不是随手排的:
|
|
590
642
|
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
* `MERGE_HEAD` 本身在那种情形下不出现,所以这一条更像是给后来者的防线
|
|
597
|
-
* - **`rebase-apply/rebasing` 排在 `rebase-apply` 之前**。`git am` 与 `git rebase --apply`
|
|
598
|
-
* 共用 `rebase-apply/` 这一个目录,里面有没有 `rebasing` 是区分两者的唯一办法;
|
|
599
|
-
* 合并成一个标注等于对着一个正在 `git am` 的用户说他在 rebase
|
|
643
|
+
* - **rebase 排在 merge 之前**:rebase 冲突停下时 git 目录里同时躺着 `rebase-merge/` 与
|
|
644
|
+
* `MERGE_MSG` / `AUTO_MERGE`,而用户处在的是 rebase——反过来排会把它标成「合并中」,
|
|
645
|
+
* 页面上看不出任何异样,只是说错了用户在干什么
|
|
646
|
+
* - **`rebase-apply/rebasing` 排在 `rebase-apply` 之前**:`git am` 与 `git rebase --apply`
|
|
647
|
+
* 共用 `rebase-apply/` 这一个目录,里面有没有 `rebasing` 是区分两者的唯一办法
|
|
600
648
|
*/
|
|
601
649
|
const PROBES = [
|
|
602
650
|
{
|
|
@@ -628,23 +676,20 @@ const PROBES = [
|
|
|
628
676
|
operation: "bisect"
|
|
629
677
|
}
|
|
630
678
|
];
|
|
631
|
-
/**
|
|
679
|
+
/** 存在与否,不区分文件还是目录——上表里两种都有,而判据只是「在不在」。 */
|
|
632
680
|
function exists(path) {
|
|
633
681
|
return access(path).then(() => true, () => false);
|
|
634
682
|
}
|
|
635
683
|
/**
|
|
636
|
-
*
|
|
684
|
+
* 当前进行中的多步操作;没有则 `undefined`。
|
|
637
685
|
*
|
|
638
|
-
* **传进来的必须是 `git rev-parse --git-dir`
|
|
639
|
-
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
* 而它不报错。`RepoInfo.gitDir` 已经是这个值,调用方照传即可。
|
|
686
|
+
* **传进来的必须是 `git rev-parse --git-dir` 的返回值,不能自己拼 `<root>/.git`**:
|
|
687
|
+
* linked worktree 下 `.git` 是个文件、真正的 git 目录在 `<主仓库>/.git/worktrees/<名>`,
|
|
688
|
+
* submodule 在 `<父仓库>/.git/modules/<路径>`。拼出来的路径在那两种仓库里永远读不到——
|
|
689
|
+
* 于是永远标不出操作,而它不报错。`RepoInfo.gitDir` 已经是这个值,调用方照传即可。
|
|
643
690
|
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
* undefined**,于是取第一个命中就是一次普通的 `find`,不必再拿下标去另一个数组里
|
|
647
|
-
* 对号入座 —— 那种写法会让「顺序对不对」多出一个不在表里的依赖。
|
|
691
|
+
* 七个探针**并发发**再按上表定序:顺序判据只有一处(上面那张表),而不是散落在一串
|
|
692
|
+
* `await` 的先后里——后者调换两行就静默换了语义。
|
|
648
693
|
*/
|
|
649
694
|
async function readOperation(gitDir) {
|
|
650
695
|
return (await Promise.all(PROBES.map(async (probe) => await exists(join(gitDir, probe.path)) ? probe.operation : void 0))).find((operation) => operation !== void 0);
|
|
@@ -653,16 +698,13 @@ async function readOperation(gitDir) {
|
|
|
653
698
|
//#endregion
|
|
654
699
|
//#region src/server/git/status.ts
|
|
655
700
|
/**
|
|
656
|
-
*
|
|
701
|
+
* 文件列表与分支状态的**唯一**数据源,一次调用同时拿到文件状态、暂存/未暂存双状态位、
|
|
657
702
|
* 重命名信息与分支 / ahead-behind。
|
|
658
703
|
*
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
* 两个参数都不能省:`-uall` 见上;`-z` 是因为不加它 git 会对含非 ASCII 字符、空格、
|
|
665
|
-
* 引号的路径做 C 风格转义并加引号(已实测)。
|
|
704
|
+
* 导出成常量是为了让降级轮询**复用逐字相同的这一条**:漏掉 `-uall` 的后果是静默的——
|
|
705
|
+
* git 会把未跟踪目录折叠成一行 `dir/`,于是在一个已存在的未跟踪目录里新增文件根本不改变
|
|
706
|
+
* 输出,轮询判「无变化」、页面不刷新。`-z` 则是因为不加它 git 会对含非 ASCII 字符、空格、
|
|
707
|
+
* 引号的路径做 C 风格转义并加引号。
|
|
666
708
|
*/
|
|
667
709
|
const STATUS_ARGS = [
|
|
668
710
|
"status",
|
|
@@ -685,10 +727,8 @@ function asStatusCode(ch) {
|
|
|
685
727
|
return ch && STATUS_CODES.has(ch) ? ch : ".";
|
|
686
728
|
}
|
|
687
729
|
/**
|
|
688
|
-
* 切出记录开头的 `n`
|
|
689
|
-
*
|
|
690
|
-
* 不能整条 `split(' ')` —— 路径本身可以含空格,而 `-z` 只把**记录**分隔成 NUL 段,
|
|
691
|
-
* 段内仍是空格分隔。
|
|
730
|
+
* 切出记录开头的 `n` 个空格分隔字段,剩下的整段就是路径。不能整条 `split(' ')`——
|
|
731
|
+
* 路径本身可以含空格,而 `-z` 只把**记录**分隔成 NUL 段,段内仍是空格分隔。
|
|
692
732
|
*/
|
|
693
733
|
function splitLeading(rec, n) {
|
|
694
734
|
const fields = [];
|
|
@@ -708,11 +748,9 @@ function parseScore(field) {
|
|
|
708
748
|
return m?.[1] ? Number(m[1]) : null;
|
|
709
749
|
}
|
|
710
750
|
/**
|
|
711
|
-
* 解析 `-z`
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
* `2 <XY> ... R<score> <新路径>\0<旧路径>`,一条记录占**两个** NUL 段(已实测)。
|
|
715
|
-
* 因此不能无状态地按 NUL 平铺切分,遇到 `2 ` 必须额外吞掉下一段作为旧路径。
|
|
751
|
+
* 解析 `-z` 输出。**NUL 在重命名记录里既是记录分隔符、又是字段分隔符**:`2 ` 记录是
|
|
752
|
+
* `2 <XY> ... R<score> <新路径>\0<旧路径>`,一条占**两个** NUL 段。因此不能无状态地按
|
|
753
|
+
* NUL 平铺切分,遇到 `2 ` 必须额外吞掉下一段作为旧路径。
|
|
716
754
|
*/
|
|
717
755
|
function parseStatus(raw) {
|
|
718
756
|
const segments = raw.split("\0");
|
|
@@ -800,15 +838,10 @@ function parseStatus(raw) {
|
|
|
800
838
|
};
|
|
801
839
|
}
|
|
802
840
|
/**
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
* 两处各自维护的巧合 —— 后者漏个 `-uall` 不报错,只是在一个已存在的未跟踪目录里
|
|
808
|
-
* 新增文件时页面不刷新(红线)。
|
|
809
|
-
*
|
|
810
|
-
* 比对解析后的结构也做得到,但那要么写一份深比较、要么 `JSON.stringify` 一遍,
|
|
811
|
-
* 而两者都会随协议类型增删字段而静默改变灵敏度。
|
|
841
|
+
* 主查询的**原始输出**,不解析。存在的唯一理由是降级轮询:它只需要回答「变没变」,而逐
|
|
842
|
+
* 字节比对原始输出既是最省的判据,也让「轮询与主查询是同一条命令」成为**构造上的事实**
|
|
843
|
+
* 而不是两处各自维护的巧合。比对解析后的结构也做得到,但深比较与 `JSON.stringify` 都会
|
|
844
|
+
* 随协议类型增删字段而静默改变灵敏度。
|
|
812
845
|
*/
|
|
813
846
|
async function readStatusRaw(root) {
|
|
814
847
|
return runGitStrict(STATUS_ARGS, root);
|
|
@@ -816,11 +849,9 @@ async function readStatusRaw(root) {
|
|
|
816
849
|
/**
|
|
817
850
|
* 变更列表 + 分支状态。
|
|
818
851
|
*
|
|
819
|
-
* 取 `RepoInfo`
|
|
820
|
-
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
* 两件事并发:`git status` 是一次子进程,`readOperation` 是七个 `access`,互不依赖。
|
|
852
|
+
* 取 `RepoInfo` 而不是两个字符串:进行中的多步操作只能从 **git 目录**下的状态文件读,而
|
|
853
|
+
* 它和工作区根是两条不同的路径——两个同类型参数并排放着,调换顺序不会报错,只会让
|
|
854
|
+
* `operation` 从此恒为空。两件事并发:status 是一次子进程,`readOperation` 是七个 `access`。
|
|
824
855
|
*/
|
|
825
856
|
async function readStatus(repo) {
|
|
826
857
|
const [raw, operation] = await Promise.all([readStatusRaw(repo.root), readOperation(repo.gitDir)]);
|
|
@@ -829,6 +860,227 @@ async function readStatus(repo) {
|
|
|
829
860
|
return status;
|
|
830
861
|
}
|
|
831
862
|
|
|
863
|
+
//#endregion
|
|
864
|
+
//#region src/server/git/tree.ts
|
|
865
|
+
/**
|
|
866
|
+
* 未跟踪那两条调用共用的尾巴。四个参数每一个都在挡一件具体的事:
|
|
867
|
+
*
|
|
868
|
+
* - `-z`:路径原样输出、按 NUL 切分(与其余所有列表类调用同一条约束);
|
|
869
|
+
* - **`--directory` 与 `--no-empty-directory` 必须同时带**。`--others` 默认展开到文件粒度,
|
|
870
|
+
* 于是一个装着 30,000 个文件的 `node_modules` 就是 30,000 条路径——**git 照常 exit 0**,
|
|
871
|
+
* 症状只是这一层慢得离谱、内存里凭空多出几 MB 字符串,没有任何东西会报错。带上之后整个
|
|
872
|
+
* 目录折叠成一条 `node_modules/`,正是树上要画的那一条;`--no-empty-directory` 顺带滤掉
|
|
873
|
+
* 折叠后为空的那些;
|
|
874
|
+
* - `--exclude-standard`:读 `.gitignore` / `.git/info/exclude` / 全局 exclude。**它对
|
|
875
|
+
* `--ignored` 那条是强制的**——单独出现时 git 直接 fatal(`needs some exclude pattern`)。
|
|
876
|
+
*/
|
|
877
|
+
const OTHERS_ARGS = [
|
|
878
|
+
"-z",
|
|
879
|
+
"--exclude-standard",
|
|
880
|
+
"--directory",
|
|
881
|
+
"--no-empty-directory"
|
|
882
|
+
];
|
|
883
|
+
/**
|
|
884
|
+
* 已跟踪的那一条**带 `--stage`**,为的是拿到 mode。
|
|
885
|
+
*
|
|
886
|
+
* 判据是 **`160000` 即 gitlink(submodule)**:它在普通输出里就是一条不带尾斜杠的路径,与
|
|
887
|
+
* 一个文件长得一模一样——照文件画出来的话,树上那一行没有展开箭头,点下去还会以「不是普通
|
|
888
|
+
* 文件」告终,而 difftab 明确支持在含 submodule 的仓库里跑。
|
|
889
|
+
*
|
|
890
|
+
* **`--stage` 与 `--others` 不能合成一条**:加上它之后 git 把未跟踪那部分整个丢掉(已实测),
|
|
891
|
+
* 所以这里是三条调用而不是两条。三条并发发出,墙上时间仍是一条的量级。
|
|
892
|
+
*/
|
|
893
|
+
const CACHED_ARGS = [
|
|
894
|
+
"ls-files",
|
|
895
|
+
"-z",
|
|
896
|
+
"--stage",
|
|
897
|
+
"--cached"
|
|
898
|
+
];
|
|
899
|
+
/**
|
|
900
|
+
* gitlink 的 mode,`--stage` 记录(`<mode> <object> <stage>\t<path>`)的开头。git 自己对
|
|
901
|
+
* submodule 的编码,不是我们的约定。按 `indexOf('\t')` 切、不上正则:这一段每个已跟踪文件
|
|
902
|
+
* 各跑一次,而要的只是「\t 后面那截」与「开头六个字符」。
|
|
903
|
+
*/
|
|
904
|
+
const GITLINK_MODE = "160000";
|
|
905
|
+
/**
|
|
906
|
+
* 一层的直接子项。
|
|
907
|
+
*
|
|
908
|
+
* 收敛判据是**「相对本层的第一段」**:`src/web/main.tsx` 相对 `src` 的第一段是 `web`,
|
|
909
|
+
* 后面还跟着 `/`,所以 `web` 是目录;`--directory` 折叠出来的 `node_modules/` 以 `/` 结尾,
|
|
910
|
+
* 同样是目录。去重后目录在前、各自按名字排序(照 VS Code 的排法)。
|
|
911
|
+
*
|
|
912
|
+
* `dir` 为空串即仓库根,此时不带 `--`:`ls-files` 不带 pathspec 就是整仓。
|
|
913
|
+
*/
|
|
914
|
+
async function readTree(root, dir) {
|
|
915
|
+
/**
|
|
916
|
+
* 两道边界一次过完,**并且拿走归一化后的那份路径**。`follow: true` 是因为兜底那条要
|
|
917
|
+
* `readdir` 它,而 `readdir` 本来就跟随最后一段;`allowRoot` 是因为目录树的第一层就是根。
|
|
918
|
+
*
|
|
919
|
+
* 归一化那半同样不是顺手:`dir/`、`./dir`、`dir//x` 都会被 `resolve()` 归成同一份,而下面
|
|
920
|
+
* 整个收敛逻辑靠 `prefix` 做前缀匹配——先前只取绝对路径、把归一结果扔掉,于是不得不在外面
|
|
921
|
+
* 补一条 `endsWith('/')` 的特判,而那一族拼法是拦不完的。
|
|
922
|
+
*/
|
|
923
|
+
const { abs, path } = await resolveInRepo(root, dir, {
|
|
924
|
+
follow: true,
|
|
925
|
+
allowRoot: true
|
|
926
|
+
});
|
|
927
|
+
const scope = path === "" ? [] : ["--", path];
|
|
928
|
+
/**
|
|
929
|
+
* **两条 `--others` 照旧按本层完整路径问,只有 git 自己失败了才退到第一段重问一次。**
|
|
930
|
+
*
|
|
931
|
+
* 要它是因为深处那一层会**直接 fatal**(`internal error - directory entry not superset of
|
|
932
|
+
* prefix`,exit 128):git 拿全部 pathspec 的公共前导目录当前缀(`a/b/c` 截到最后一个 `/`,
|
|
933
|
+
* 得 `a/b/`),而 `--ignored` 那条报的是**排除规则命中的那一层**(`.gitignore` 里的
|
|
934
|
+
* `vendor/`)、与 pathspec 有多深无关——吐目录条目之前那道断言要求前缀不比条目长。于是
|
|
935
|
+
* **被忽略的那一片里深度 ≥ 3 的那一层必炸,深度 ≤ 2 恒安全**(前缀最长就是 `a/`,正好等于
|
|
936
|
+
* 最短的那条记录)。`-C` 进那个目录、pathspec 带尾斜杠都绕不过;第一段不含 `/`,前缀因此
|
|
937
|
+
* 恒为空,断言永远成立。页面上炸出来的样子是那一层写着一行 `git ls-files … failed (exit)`。
|
|
938
|
+
*
|
|
939
|
+
* **但第一段只能当兜底,不能当默认**——放宽 pathspec 会静默改掉另外两件事的答案:
|
|
940
|
+
*
|
|
941
|
+
* - **被忽略的那一片嵌在一个未跟踪目录里时,放宽之后 `--ignored` 那条一条都不回**(实测:
|
|
942
|
+
* `u/` 整个未跟踪、`u/ig/` 被忽略,问 `-- u/ig` 回 `u/ig/`,问 `-- u` 回空)。那一层于是
|
|
943
|
+
* 继承「不灰显」,页面上只是它不再灰了;
|
|
944
|
+
* - **`?path=<未跟踪目录里的一个文件>` 会从 400 变成 500**:窄 pathspec 下 git 吐的是这个
|
|
945
|
+
* 文件自己(下面据此判 `selfKind === 'file'`),放宽之后它被折叠进 `a/`,于是没人认出这是
|
|
946
|
+
* 坏请求,兜底拿一个普通文件去 `readdir`,以 `ENOTDIR` 收场。
|
|
947
|
+
*
|
|
948
|
+
* 所以退让只发生在 git 已经答不出东西的那一刻,代价范围也就限死在「本来是个 500」的那一层。
|
|
949
|
+
* 重问一次拿到的是折叠记录,之后走的是下面读一层磁盘的兜底——折叠层以下 git 本就答不出内容。
|
|
950
|
+
*
|
|
951
|
+
* 判据用「非零退出」而不是去比对那句 fatal 的字面量:这两条调用是只读列举,非零退出没有第二
|
|
952
|
+
* 种解释,而按消息匹配要赌它逐字不变。重问那次再失败就把**原来那个**错误抛出去。
|
|
953
|
+
*/
|
|
954
|
+
const firstSlash = path.indexOf("/");
|
|
955
|
+
const runOthers = (args) => runGitStrict([...args, ...scope], root).catch((cause) => {
|
|
956
|
+
if (!(cause instanceof GitError) || cause.kind !== "exit" || firstSlash === -1) throw cause;
|
|
957
|
+
return runGitStrict([
|
|
958
|
+
...args,
|
|
959
|
+
"--",
|
|
960
|
+
path.slice(0, firstSlash)
|
|
961
|
+
], root).catch(() => {
|
|
962
|
+
throw cause;
|
|
963
|
+
});
|
|
964
|
+
});
|
|
965
|
+
const [cached, others, ignoredOut] = await Promise.all([
|
|
966
|
+
runGitStrict([...CACHED_ARGS, ...scope], root),
|
|
967
|
+
runOthers([
|
|
968
|
+
"ls-files",
|
|
969
|
+
...OTHERS_ARGS,
|
|
970
|
+
"--others"
|
|
971
|
+
]),
|
|
972
|
+
runOthers([
|
|
973
|
+
"ls-files",
|
|
974
|
+
...OTHERS_ARGS,
|
|
975
|
+
"--others",
|
|
976
|
+
"--ignored"
|
|
977
|
+
])
|
|
978
|
+
]);
|
|
979
|
+
const prefix = path === "" ? "" : `${path}/`;
|
|
980
|
+
/**
|
|
981
|
+
* 这一层落在一片被折叠的区域里(`null` 即没有),以及那一片是哪一档。
|
|
982
|
+
*
|
|
983
|
+
* `--directory` 是它该有的行为——`vendor/` 与 `node_modules/` 正是靠它才没有变成三万条
|
|
984
|
+
* 路径——但代价是 git 从此再也答不出那里面有什么。而且**它折叠在最高那一层**:问
|
|
985
|
+
* `dist/server` 回来的仍是 `dist/`(已实测;三种 pathspec 写法与 `-C` 进去都一样)。
|
|
986
|
+
*
|
|
987
|
+
* **判据因此是「回来的这条是本层自己**或**它的某个祖先」,不是「正好等于本层」**:只认
|
|
988
|
+
* 相等时,`dist/` 这条既不等于 `dist/server/`、又过不了下面那道 `startsWith(prefix)`,
|
|
989
|
+
* 于是这一层两手空空地返回——页面上就是一个展开后写着 Empty 的目录,而里面明明有东西。
|
|
990
|
+
*/
|
|
991
|
+
let collapsedAs = null;
|
|
992
|
+
const byName = /* @__PURE__ */ new Map();
|
|
993
|
+
/** 一条路径 → 这一层里的那个条目。`isDir` 由调用方按各自的格式判出来后传进来。 */
|
|
994
|
+
const add = (path, isIgnored, gitlink = false) => {
|
|
995
|
+
if (!path.startsWith(prefix)) return;
|
|
996
|
+
const rest = path.slice(prefix.length);
|
|
997
|
+
const slash = rest.indexOf("/");
|
|
998
|
+
const name = slash === -1 ? rest : rest.slice(0, slash);
|
|
999
|
+
if (name === "" || byName.has(name)) return;
|
|
1000
|
+
byName.set(name, {
|
|
1001
|
+
name,
|
|
1002
|
+
path: `${prefix}${name}`,
|
|
1003
|
+
kind: slash === -1 && !gitlink ? "file" : "directory",
|
|
1004
|
+
ignored: isIgnored
|
|
1005
|
+
});
|
|
1006
|
+
};
|
|
1007
|
+
/**
|
|
1008
|
+
* git 提到了**本层自己**这条路径(而不是它底下的东西),说的是哪一种。两种都要:
|
|
1009
|
+
* `gitlink` 是下面那条兜底的第二个正面判据;`file` 说明**问错了**——有人把一个文件当目录
|
|
1010
|
+
* 展开,那是坏请求,不是一个空目录。
|
|
1011
|
+
*/
|
|
1012
|
+
let selfKind = null;
|
|
1013
|
+
for (const segment of cached.split("\0")) {
|
|
1014
|
+
const tab = segment.indexOf(" ");
|
|
1015
|
+
if (tab === -1) continue;
|
|
1016
|
+
const entryPath = segment.slice(tab + 1);
|
|
1017
|
+
const gitlink = segment.startsWith(GITLINK_MODE);
|
|
1018
|
+
if (entryPath === path) {
|
|
1019
|
+
selfKind = gitlink ? "gitlink" : "file";
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
add(entryPath, false, gitlink);
|
|
1023
|
+
}
|
|
1024
|
+
const collectOthers = (output, isIgnored) => {
|
|
1025
|
+
for (const line of output.split("\0")) {
|
|
1026
|
+
if (line === "") continue;
|
|
1027
|
+
if (line.endsWith("/") && prefix.startsWith(line)) {
|
|
1028
|
+
collapsedAs = isIgnored;
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
if (line === path) {
|
|
1032
|
+
selfKind ??= "file";
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
add(line, isIgnored);
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
collectOthers(others, false);
|
|
1039
|
+
collectOthers(ignoredOut, true);
|
|
1040
|
+
/**
|
|
1041
|
+
* **git 答不出这一层时读一次磁盘**,只读一层。两种形态都会走到这里,而**两种手上都有正面
|
|
1042
|
+
* 证据**——判据因此是那两条,不是「一条子项都没有」:
|
|
1043
|
+
*
|
|
1044
|
+
* - `--directory` 把这一片折叠了(`node_modules/` 里面、`dist/server` 之类);
|
|
1045
|
+
* - 这是一个 **submodule**,父仓库的 `ls-files` 对它只有一条 gitlink 记录(`selfKind`)。
|
|
1046
|
+
*
|
|
1047
|
+
* 写成「空就兜底」时它是个 catch-all:`?path=<一个文件>` 与 `?path=<不存在的路径>` 同样
|
|
1048
|
+
* 落进来,`readdir` 抛 `ENOTDIR` / `ENOENT` 被咽掉,页面上是一个「展开后是空的目录」——
|
|
1049
|
+
* 而它们本该是 400 / 404。**为了不吞掉 `EACCES` 而加的那道 code 过滤,本身就是在给一个过宽
|
|
1050
|
+
* 的开关打补丁**;判据换成正面证据之后,那道过滤连同它一起没了。
|
|
1051
|
+
*
|
|
1052
|
+
* 这里读磁盘不需要任何 git 知识,这正是它成立的理由:两条证据都说明整棵子树同属一档,于是
|
|
1053
|
+
* 每个子项的 `ignored` 直接继承(submodule 那一路继承「不灰显」)。`withFileTypes` 的
|
|
1054
|
+
* dirent 不跟随符号链接(`isDirectory()` 对指向目录的链接为 false),而 `dir` 自己那一段
|
|
1055
|
+
* 已经由上面那道 `follow: true` 的边界验过。
|
|
1056
|
+
*/
|
|
1057
|
+
if (selfKind === "file") throw new WorktreeError("invalid-path", "not a directory");
|
|
1058
|
+
if (byName.size === 0 && (collapsedAs !== null || selfKind === "gitlink")) {
|
|
1059
|
+
const inherited = collapsedAs ?? false;
|
|
1060
|
+
for (const child of await readdir(abs, { withFileTypes: true })) {
|
|
1061
|
+
if (child.name === ".git") continue;
|
|
1062
|
+
byName.set(child.name, {
|
|
1063
|
+
name: child.name,
|
|
1064
|
+
path: `${prefix}${child.name}`,
|
|
1065
|
+
kind: child.isDirectory() ? "directory" : "file",
|
|
1066
|
+
ignored: inherited
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* **回的是完整 payload,`path` 用归一后的那份。** http 那层因此只负责发,与 `/api/diff`、
|
|
1072
|
+
* `/api/file` 一致;先前由 http 拿客户端原样传来的 query 去拼,既不对称,也让「归一化」
|
|
1073
|
+
* 这件事没有 owner。
|
|
1074
|
+
*/
|
|
1075
|
+
return {
|
|
1076
|
+
path,
|
|
1077
|
+
entries: [...byName.values()].sort((a, b) => {
|
|
1078
|
+
if (a.kind !== b.kind) return a.kind === "directory" ? -1 : 1;
|
|
1079
|
+
return a.name.localeCompare(b.name);
|
|
1080
|
+
})
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
|
|
832
1084
|
//#endregion
|
|
833
1085
|
//#region src/server/watch/tier.ts
|
|
834
1086
|
/** `fs.watch` 的 `ignore` 选项自此版本可用。 */
|
|
@@ -838,10 +1090,8 @@ const IGNORE_SINCE = {
|
|
|
838
1090
|
patch: 0
|
|
839
1091
|
};
|
|
840
1092
|
/**
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
* 没有它,S3b2 的六条档位验收项在单机上一条都无从自查 —— 一台机器只有一个 Node
|
|
844
|
-
* 版本、一个平台,而三档正是按这两者分的。它不是给用户的开关,README 不写它。
|
|
1093
|
+
* 强制指定档位的**内部**环境变量。没有它,档位相关的验收在单机上一条都无从自查——一台
|
|
1094
|
+
* 机器只有一个 Node 版本、一个平台,而三档正是按这两者分的。它不是给用户的开关。
|
|
845
1095
|
*/
|
|
846
1096
|
const TIER_ENV = "DIFFTAB_WATCH_TIER";
|
|
847
1097
|
/** 档位环境变量的取值不合法。由 main() 收成一句话友好报错。 */
|
|
@@ -852,10 +1102,8 @@ var WatchTierError = class extends Error {
|
|
|
852
1102
|
}
|
|
853
1103
|
};
|
|
854
1104
|
/**
|
|
855
|
-
* `24.14.1` / `26.0.0-nightly20260101` → 三元组 +
|
|
856
|
-
*
|
|
857
|
-
* 解析不出来返回 null,调用方按「没有 `ignore`」处理 —— 见 `supportsIgnoreOption`
|
|
858
|
-
* 里那段关于误判方向的说明。
|
|
1105
|
+
* `24.14.1` / `26.0.0-nightly20260101` → 三元组 + 是否带预发布标签。解析不出来返回 null,
|
|
1106
|
+
* 调用方按「没有 `ignore`」处理。
|
|
859
1107
|
*/
|
|
860
1108
|
function parseNodeVersion(version) {
|
|
861
1109
|
const m = /^v?(\d+)\.(\d+)\.(\d+)(-.*)?$/.exec(version.trim());
|
|
@@ -868,14 +1116,12 @@ function parseNodeVersion(version) {
|
|
|
868
1116
|
};
|
|
869
1117
|
}
|
|
870
1118
|
/**
|
|
871
|
-
* 当前 Node 是否具备 `fs.watch` 的 `ignore`
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
* 1.5s 轮询(功能完整,只是不即时);误判成有,代价是上面那条注释里的 ENOSPC。
|
|
875
|
-
* 两种误判的代价差着一个数量级,判据不确定时只能倒向便宜的那一侧。
|
|
1119
|
+
* 当前 Node 是否具备 `fs.watch` 的 `ignore` 选项。**解析失败一律按「没有」处理**,方向是
|
|
1120
|
+
* 刻意的:误判成没有,代价是 Linux 上退化为 1.5s 轮询(功能完整,只是不即时);误判成有,
|
|
1121
|
+
* 代价是上面那条 ENOSPC。两种误判差着一个数量级,判据不确定时只能倒向便宜的那一侧。
|
|
876
1122
|
*
|
|
877
|
-
* 预发布标签按 semver 的规矩算作**低于**同版本正式版(`24.14.0-rc.1 < 24.14.0`)
|
|
878
|
-
*
|
|
1123
|
+
* 预发布标签按 semver 的规矩算作**低于**同版本正式版(`24.14.0-rc.1 < 24.14.0`):那正是
|
|
1124
|
+
* 选项刚合入、行为还可能变的窗口。
|
|
879
1125
|
*/
|
|
880
1126
|
function supportsIgnoreOption(nodeVersion) {
|
|
881
1127
|
const v = parseNodeVersion(nodeVersion);
|
|
@@ -888,21 +1134,19 @@ function supportsIgnoreOption(nodeVersion) {
|
|
|
888
1134
|
/**
|
|
889
1135
|
* 档位判定。
|
|
890
1136
|
*
|
|
891
|
-
* - **A
|
|
892
|
-
* - **B
|
|
893
|
-
*
|
|
894
|
-
* - **C
|
|
1137
|
+
* - **A**:有 `ignore`,三端通用——Linux 上是注册前跳过,正是配额问题的官方解法
|
|
1138
|
+
* - **B**:没有 `ignore`,但在 macOS / Windows 上走原生 FSEvents / `ReadDirectoryChangesW`,
|
|
1139
|
+
* 单句柄监听整棵树,本就没有配额问题,回调里自己过滤即可
|
|
1140
|
+
* - **C**:没有 `ignore` 且在 Linux 上——**不建递归 watch**,工作区改动走轮询
|
|
895
1141
|
*/
|
|
896
1142
|
function detectTier(nodeVersion, platform) {
|
|
897
1143
|
if (supportsIgnoreOption(nodeVersion)) return "A";
|
|
898
1144
|
return platform === "linux" ? "C" : "B";
|
|
899
1145
|
}
|
|
900
1146
|
/**
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
*
|
|
904
|
-
* 自动判定的话在本机上多半照样给出 B 档,于是「我验过 B 档了」这个结论建立在
|
|
905
|
-
* 一次根本没生效的强制指定上 —— 而 S3b2 的六条验收项全都压在这个变量上。
|
|
1147
|
+
* 实际生效的档位:环境变量优先,否则按运行时判定。**取值不合法时抛错而不是退回自动判定**:
|
|
1148
|
+
* `DIFFTAB_WATCH_TIER=b` 那种手滑退回自动判定后,在本机上多半照样给出 B 档,于是「我验过
|
|
1149
|
+
* B 档了」这个结论建立在一次根本没生效的强制指定上。
|
|
906
1150
|
*/
|
|
907
1151
|
function forcedTier(env) {
|
|
908
1152
|
const forced = env[TIER_ENV];
|
|
@@ -915,23 +1159,19 @@ function resolveTier(env = process.env, nodeVersion = process.versions.node, pla
|
|
|
915
1159
|
return forcedTier(env) ?? detectTier(nodeVersion, platform);
|
|
916
1160
|
}
|
|
917
1161
|
/**
|
|
918
|
-
* 该档位下**工作区通路**的既定形态(`WatchState.mode`)
|
|
919
|
-
*
|
|
920
|
-
*
|
|
921
|
-
* 答什么」**:监听懒起(见 http/server.ts),起了之后真实取值由 `WatchHandle.mode`
|
|
922
|
-
* 给 —— A / B 档运行中落到轮询兜底时,那一侧会翻成 `polling`。
|
|
1162
|
+
* 该档位下**工作区通路**的既定形态(`WatchState.mode`)。**它只是「还没起监听时答什么」**:
|
|
1163
|
+
* 监听懒起,起了之后真实取值由 `WatchHandle.mode` 给——A / B 档运行中落到轮询兜底时,
|
|
1164
|
+
* 那一侧会翻成 `polling`。
|
|
923
1165
|
*/
|
|
924
1166
|
function initialMode(tier) {
|
|
925
1167
|
return tier === "C" ? "polling" : "native";
|
|
926
1168
|
}
|
|
927
1169
|
/**
|
|
928
|
-
* 强制指定 A 档、但这个 Node 根本没有 `ignore`
|
|
1170
|
+
* 强制指定 A 档、但这个 Node 根本没有 `ignore` 时的一句提醒(没有则返回 null)。
|
|
929
1171
|
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
* 「A 档」跑的是一个**没有任何过滤的递归 watch**:在 Linux 上那正是耗尽
|
|
934
|
-
* `fs.inotify.max_user_watches` 的那条路,而结论会写成「我验过 A 档了」。
|
|
1172
|
+
* **不是报错**:「三档均可通过内部环境变量强制指定」是既定用法,在 Node 22 上拒绝启动会把
|
|
1173
|
+
* 它推翻。但沉默同样不行——Node 对未知选项是**静默忽略**,于是这次「A 档」跑的是一个
|
|
1174
|
+
* **没有任何过滤的递归 watch**,而结论会写成「我验过 A 档了」。
|
|
935
1175
|
*/
|
|
936
1176
|
function forcedTierWarning(env = process.env, nodeVersion = process.versions.node) {
|
|
937
1177
|
if (forcedTier(env) !== "A" || supportsIgnoreOption(nodeVersion)) return null;
|
|
@@ -942,10 +1182,8 @@ function forcedTierWarning(env = process.env, nodeVersion = process.versions.nod
|
|
|
942
1182
|
//#endregion
|
|
943
1183
|
//#region src/server/watch/ignore.ts
|
|
944
1184
|
/**
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
* `.git` 在集合里**与档位无关**:工作区那条递归 watch 绝不能进 `.git`(一次 gc 就是
|
|
948
|
-
* 几万个条目),`.git` 侧另有目录级非递归 watch 盯着(见 watcher.ts)。
|
|
1185
|
+
* 路径任一段命中即忽略。`.git` 在集合里**与档位无关**:工作区那条递归 watch 绝不能进
|
|
1186
|
+
* `.git`(一次 gc 就是几万个条目),`.git` 侧另有目录级非递归 watch 盯着。
|
|
949
1187
|
*/
|
|
950
1188
|
const IGNORE_NAMES = /* @__PURE__ */ new Set([
|
|
951
1189
|
"node_modules",
|
|
@@ -955,70 +1193,48 @@ const IGNORE_NAMES = /* @__PURE__ */ new Set([
|
|
|
955
1193
|
".next",
|
|
956
1194
|
"build"
|
|
957
1195
|
]);
|
|
958
|
-
/**
|
|
1196
|
+
/** 路径分隔符两种都切:Windows 的原生 watcher 给的是反斜杠分隔的相对路径。 */
|
|
959
1197
|
const SEPARATOR = /[\\/]/;
|
|
960
1198
|
/**
|
|
961
|
-
*
|
|
962
|
-
*
|
|
963
|
-
*
|
|
964
|
-
* Linux 上**原样返回**:那里的文件系统区分大小写,`Node_Modules` 与 `node_modules`
|
|
965
|
-
* 是两个不同的目录,归一等于把一个用户真的想看的目录悄悄屏蔽掉。
|
|
1199
|
+
* 大小写归一,对齐 `ignore` 内部的 `nocase: isWindows || isMacOS`。**不导出**:多一个入口
|
|
1200
|
+
* 就多一份会跟着漂的契约。Linux 上**原样返回**——那里的文件系统区分大小写,`Node_Modules`
|
|
1201
|
+
* 与 `node_modules` 是两个不同的目录,归一等于把一个用户真的想看的目录悄悄屏蔽掉。
|
|
966
1202
|
*/
|
|
967
1203
|
function caseFold(segment, platform) {
|
|
968
1204
|
return platform === "win32" || platform === "darwin" ? segment.toLowerCase() : segment;
|
|
969
1205
|
}
|
|
970
1206
|
/**
|
|
971
|
-
*
|
|
972
|
-
*
|
|
973
|
-
*
|
|
974
|
-
* 收到的是**相对被监听目录的路径**(A 档 Linux 侧是遍历到的条目,A 档 macOS/Windows
|
|
975
|
-
* 与 B 档是事件路径),不是绝对路径 —— 传绝对路径进来也能工作,只是仓库自身若躺在
|
|
976
|
-
* 某个叫 `build` 的目录下就会整片被忽略,所以调用方不该那么用。
|
|
1207
|
+
* 造一个绑定平台的匹配器。**平台可注入是为了让三端的归一行为都能在一台机器上验**。收到的
|
|
1208
|
+
* 是**相对被监听目录的路径**,不是绝对路径——传绝对路径进来也能工作,只是仓库自身若躺在
|
|
1209
|
+
* 某个叫 `build` 的目录下就会整片被忽略,所以调用方不该那么用。
|
|
977
1210
|
*/
|
|
978
1211
|
function createIsIgnored(platform = process.platform) {
|
|
979
1212
|
return (path) => path.split(SEPARATOR).some((segment) => IGNORE_NAMES.has(caseFold(segment, platform)));
|
|
980
1213
|
}
|
|
981
|
-
/** 当前平台的那一份。A 档传给 `ignore`、B
|
|
1214
|
+
/** 当前平台的那一份。A 档传给 `ignore`、B 档在回调最前面调,是**同一个函数**。 */
|
|
982
1215
|
const isIgnored = createIsIgnored();
|
|
983
1216
|
|
|
984
1217
|
//#endregion
|
|
985
1218
|
//#region src/server/watch/watcher.ts
|
|
986
1219
|
/**
|
|
987
|
-
*
|
|
988
|
-
*
|
|
989
|
-
* **在 Linux 上这是必需项而非优化项**:用户态递归实现初次遍历目录树时会对遍历到的
|
|
990
|
-
* 每个条目 emit 一次事件,启动瞬间即产生一波与实际变更无关的风暴。`.git` 侧同样需要:
|
|
991
|
-
* 一条 `git commit` 会连着写 index.lock、index、COMMIT_EDITMSG、HEAD 的 reflog、
|
|
992
|
-
* refs/heads/<分支>,五六个事件是常态。
|
|
1220
|
+
* 事件合并窗口。**Linux 上是必需项而非优化项**:用户态递归实现初次遍历时对每个条目都
|
|
1221
|
+
* emit 一次,启动瞬间就是一波风暴;`.git` 侧一条 `git commit` 也是五六个事件。
|
|
993
1222
|
*/
|
|
994
1223
|
const DEBOUNCE_MS = 150;
|
|
995
1224
|
/** 降级轮询的周期(1.5s)。C 档的工作区通路也是它。 */
|
|
996
1225
|
const POLL_MS = 1500;
|
|
997
1226
|
/**
|
|
998
|
-
* **原生档(A / B)的低频安全轮询周期**。
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
* 一次都不 emit(2026-08-18 实测,推翻了此前的源码推断)—— 没轮上注册的那些
|
|
1002
|
-
* 目录里,改一个**启动前就存在**的文件从此静默丢失,`mode` 还一直说 `native`。
|
|
1003
|
-
* 原生监听少报了什么是没法从监听那一侧知道的,只有拿 status 输出本身去比才看得见。
|
|
1004
|
-
*
|
|
1005
|
-
* **取 30s 而不是 1.5s**:要求「原生监听模式下空闲 CPU 接近零」,一次 status 几十
|
|
1006
|
-
* 毫秒,30s 一拍的占空比是千分之几;代价是那个病态场景下最坏 30s 的滞后。
|
|
1227
|
+
* **原生档(A / B)的低频安全轮询周期**。inotify 配额在**遍历途中**耗尽时 Node 一次都
|
|
1228
|
+
* 不 emit,改动从此静默丢失而 `mode` 还说 `native`——只有拿 status 输出本身去比才看得
|
|
1229
|
+
* 见。30s 而非 1.5s 是为了让原生档空闲 CPU 接近零,代价是最坏 30s 滞后。
|
|
1007
1230
|
*/
|
|
1008
1231
|
const SAFETY_POLL_MS = 3e4;
|
|
1009
1232
|
/**
|
|
1010
|
-
* `.git`
|
|
1011
|
-
*
|
|
1012
|
-
* **一律是目录,绝不是单个文件**:Linux / macOS 上 watch 绑的是 inode,而 git 写
|
|
1013
|
-
* `HEAD` / `index` 走的是「写临时文件 + 原子 rename」,新文件是新 inode,对文件建的
|
|
1014
|
-
* watch 从此静默失效。
|
|
1233
|
+
* `.git` 下需要盯的目录清单。**一律是目录,绝不是单个文件**:watch 绑 inode,而 git 写
|
|
1234
|
+
* `HEAD` / `index` 走「写临时文件 + 原子 rename」,对文件建的 watch 从此静默失效。
|
|
1015
1235
|
*
|
|
1016
|
-
* **绝不递归、更绝不进 `objects
|
|
1017
|
-
*
|
|
1018
|
-
* 本身 —— 提交写 `index` 与 `COMMIT_EDITMSG`、切分支写 `HEAD`、fetch 写 `FETCH_HEAD`,
|
|
1019
|
-
* 全都直接落在它下面。`refs/` 那两条是补 `refs/heads/<分支>` 这种一层深的更新,
|
|
1020
|
-
* 属于锦上添花;`refs/heads/feature/x` 这类嵌套分支名照样只由 `index` 那一路兜住,
|
|
1021
|
-
* 而那一路本来就够。
|
|
1236
|
+
* **绝不递归、更绝不进 `objects`**(一次 gc 就是几万个条目)。主力是 `gitDir` 本身——
|
|
1237
|
+
* 提交、切分支、fetch 都直接写在它下面;`refs/` 那两条只补一层深的更新。
|
|
1022
1238
|
*/
|
|
1023
1239
|
function gitWatchDirs(gitDir) {
|
|
1024
1240
|
const dirs = [];
|
|
@@ -1036,13 +1252,9 @@ function gitWatchDirs(gitDir) {
|
|
|
1036
1252
|
return dirs;
|
|
1037
1253
|
}
|
|
1038
1254
|
/**
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1042
|
-
* `GIT_OPTIONAL_LOCKS=0`(红线)。不设它的话 `git status` 会把 stat 缓存写回
|
|
1043
|
-
* `.git/index`,于是每次刷新都写一次 index、每次写 index 都触发一次刷新 —— 一个不报错、
|
|
1044
|
-
* 只是 CPU 常年 1% 的自激循环,而 status 的输出从头到尾都是对的。轮询那条路上这
|
|
1045
|
-
* 一点更要命:它每 1.5s 主动跑一次 status。
|
|
1255
|
+
* 起监听。不自激全靠封装层那条 `GIT_OPTIONAL_LOCKS=0`:不设它时 `git status` 把 stat
|
|
1256
|
+
* 缓存写回 `.git/index`,于是每次刷新写一次 index、每次写 index 又触发一次刷新,而
|
|
1257
|
+
* status 的输出从头到尾都是对的。
|
|
1046
1258
|
*/
|
|
1047
1259
|
function createWatcher(options) {
|
|
1048
1260
|
const { gitDir, repoRoot, tier, onChange, pollStatus, onDegrade, debounceMs = 150, pollMs = POLL_MS, safetyPollMs = SAFETY_POLL_MS } = options;
|
|
@@ -1056,13 +1268,9 @@ function createWatcher(options) {
|
|
|
1056
1268
|
let pollInFlight = false;
|
|
1057
1269
|
let closed = false;
|
|
1058
1270
|
/**
|
|
1059
|
-
* **不是「每来一个事件就把窗口往后推」的那种 debounce
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
* 正是持续写入。这里是「第一个事件起一个窗口,窗口内的后续事件被它吞掉」,
|
|
1063
|
-
* 于是风暴期间也保证每 `debounceMs` 至少刷新一次。
|
|
1064
|
-
*
|
|
1065
|
-
* `.git` 侧、工作区侧与轮询共用这一个窗口(见文件头)。
|
|
1271
|
+
* **不是「每来一个事件就把窗口往后推」的那种 debounce**——那一种在持续写入(agent 连
|
|
1272
|
+
* 着改几十个文件)下会一直不触发。这里窗口内的后续事件被第一个吞掉,风暴期间也保证每
|
|
1273
|
+
* `debounceMs` 至少刷新一次。
|
|
1066
1274
|
*/
|
|
1067
1275
|
const schedule = () => {
|
|
1068
1276
|
if (closed || timer !== null) return;
|
|
@@ -1073,14 +1281,9 @@ function createWatcher(options) {
|
|
|
1073
1281
|
timer.unref();
|
|
1074
1282
|
};
|
|
1075
1283
|
/**
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
1079
|
-
* 1.5s 内跑得完,`setInterval` 会让它们叠着跑。
|
|
1080
|
-
*
|
|
1081
|
-
* **首拍只建立基线,不触发刷新**(`lastSnapshot === null`):降级那一刻页面刚
|
|
1082
|
-
* 重取过,再推一次纯属白刷。探针失败(仓库正被 index.lock 挡住、git 一时不可用)
|
|
1083
|
-
* 也不终止轮询 —— 那是暂态,停下来就再也起不来了。
|
|
1284
|
+
* 轮询一拍。`setTimeout` 链而不是 `setInterval`——探针是一次 git 子进程,大仓库上未必
|
|
1285
|
+
* 1.5s 内跑得完,会叠着跑。**首拍只建立基线不刷新**;探针失败(index.lock 挡着)也不
|
|
1286
|
+
* 终止轮询,那是暂态,停下来就再也起不来了。
|
|
1084
1287
|
*/
|
|
1085
1288
|
const pollOnce = async () => {
|
|
1086
1289
|
pollInFlight = true;
|
|
@@ -1098,12 +1301,8 @@ function createWatcher(options) {
|
|
|
1098
1301
|
armPoll();
|
|
1099
1302
|
};
|
|
1100
1303
|
/**
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1103
|
-
* 要记得把前一个停掉 —— 忘了不报错,只是从此每 30s 白跑一次 git。
|
|
1104
|
-
*
|
|
1105
|
-
* 每次都先清掉在等的那一拍:降级时正是靠这一下把 30s 的等待按新周期提前,否则
|
|
1106
|
-
* 「已降级」之后最长还要再等 30s 才有第一次轮询,而页面上标的已经是轮询了。
|
|
1304
|
+
* 排下一拍。**同一个循环的两个周期,不是两套机制**:原生档 30s、降级后自动收到 1.5s。
|
|
1305
|
+
* 每次都先清掉在等的那一拍——降级正是靠这一下把 30s 的等待按新周期提前。
|
|
1107
1306
|
*/
|
|
1108
1307
|
const armPoll = () => {
|
|
1109
1308
|
if (closed) return;
|
|
@@ -1111,24 +1310,19 @@ function createWatcher(options) {
|
|
|
1111
1310
|
pollTimer = setTimeout(() => void pollOnce(), polling ? pollMs : safetyPollMs);
|
|
1112
1311
|
pollTimer.unref();
|
|
1113
1312
|
};
|
|
1114
|
-
/**
|
|
1313
|
+
/** 起轮询循环。**只起一次**——起两次就是两条链各自排各自的定时器。 */
|
|
1115
1314
|
const startPolling = () => {
|
|
1116
1315
|
if (pollLoopStarted) return;
|
|
1117
1316
|
pollLoopStarted = true;
|
|
1118
1317
|
pollOnce();
|
|
1119
1318
|
};
|
|
1120
1319
|
/**
|
|
1121
|
-
* 转到轮询。**`cause` 为 null
|
|
1122
|
-
*
|
|
1123
|
-
*
|
|
1124
|
-
* 只有一个入口(而不是「起轮询」+「降级」两个),是因为 `polling` 这个闩只该在
|
|
1125
|
-
* 一处合上:两个入口各带一份 `closed || polling` 判断时,「谁先谁后、谁的守卫
|
|
1126
|
-
* 拦住了谁」就成了调用点必须自己想清楚的事,而想错的症状是多推一次降级通知。
|
|
1320
|
+
* 转到轮询。**`cause` 为 null 表示这是该档位的既定形态,不是降级**(C 档一开始就走这条
|
|
1321
|
+
* 路)。只有一个入口而不是「起轮询」+「降级」两个,是因为 `polling` 这个闩只该在一处
|
|
1322
|
+
* 合上,两个入口时想错守卫顺序的症状是多推一次降级通知。
|
|
1127
1323
|
*
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1130
|
-
* 恢复要么靠定期重试(在真的没有配额时就是每隔一会儿遍历一次整棵树)、要么靠猜,
|
|
1131
|
-
* 而轮询已经是功能完整的通路,唯一代价是 1.5s 延迟。
|
|
1324
|
+
* **不可逆,而且只上报一次**:一次 ENOSPC 往往连着把几个 watcher 全打下来。也不尝试
|
|
1325
|
+
* 恢复原生监听——轮询是功能完整的通路,唯一代价是 1.5s 延迟。
|
|
1132
1326
|
*/
|
|
1133
1327
|
const usePolling = (cause) => {
|
|
1134
1328
|
if (closed || polling) return;
|
|
@@ -1139,49 +1333,34 @@ function createWatcher(options) {
|
|
|
1139
1333
|
};
|
|
1140
1334
|
const toError = (cause) => cause instanceof Error ? cause : new Error(String(cause));
|
|
1141
1335
|
/**
|
|
1142
|
-
* C 档的轮询要在 `.git`
|
|
1143
|
-
*
|
|
1144
|
-
*
|
|
1145
|
-
*
|
|
1146
|
-
* 「哪档以轮询为既定形态」只此一份判据(`initialMode`)—— server 那侧在监听还没
|
|
1147
|
-
* 懒起时也读它来答 `/api/state`,各写各的话两边会在加档位时静默分家。
|
|
1336
|
+
* C 档的轮询要在 `.git` 侧之前起,否则那边一个 watch 失败会被当成降级上报,而 `mode`
|
|
1337
|
+
* 从头到尾就是 `polling`。「哪档以轮询为既定形态」只此一份判据(`initialMode`)——
|
|
1338
|
+
* server 那侧也读它来答 `/api/state`,各写各的会在加档位时静默分家。
|
|
1148
1339
|
*/
|
|
1149
1340
|
if (initialMode(tier) === "polling") usePolling(null);
|
|
1150
1341
|
else
|
|
1151
1342
|
/**
|
|
1152
|
-
*
|
|
1153
|
-
*
|
|
1154
|
-
* 否则页面会把一次完全正常的运行标成降级。首拍照例只建立基线。
|
|
1343
|
+
* 原生档的安全轮询**直接起循环而不经 `usePolling`**:那个闩一合上就意味着「已降级」,
|
|
1344
|
+
* 而这里没有任何东西坏掉,`mode` 必须还是 `native`。
|
|
1155
1345
|
*/
|
|
1156
1346
|
startPolling();
|
|
1157
1347
|
for (const dir of gitWatchDirs(gitDir)) {
|
|
1158
1348
|
/**
|
|
1159
|
-
* **只有 `gitDir`
|
|
1160
|
-
*
|
|
1161
|
-
* `refs/` 那两条是锦上添花,它们失败(权限、网络盘)不值得把整个工具**不可逆地**
|
|
1162
|
-
* 拖进轮询 —— 那样 UI 会永远标着「轮询刷新」、进程永远每 1.5s 起一次 git,
|
|
1163
|
-
* 而实际上什么都没坏。
|
|
1349
|
+
* **只有 `gitDir` 自己那条算主力**:`refs/` 那两条失败(权限、网络盘)不值得把整个
|
|
1350
|
+
* 工具**不可逆地**拖进轮询——那样 UI 永远标着「轮询刷新」,而实际上什么都没坏。
|
|
1164
1351
|
*/
|
|
1165
1352
|
const primary = dir === gitDir;
|
|
1166
1353
|
try {
|
|
1167
1354
|
/**
|
|
1168
|
-
* **不传 `recursive
|
|
1169
|
-
*
|
|
1170
|
-
*
|
|
1171
|
-
* **filename 一概不看**。它可能为 null,而这几个目录里的任何写入都
|
|
1172
|
-
* 值得刷新一次:非递归就是真的非递归 —— macOS 已实测(2026-08-11),
|
|
1173
|
-
* Linux 的 inotify 与 Windows 的 `bWatchSubtree=FALSE` 按机制如此 —— 于是
|
|
1174
|
-
* `objects/` 的海量写入根本到不了这里,没有需要按名字排除的东西。曾按
|
|
1175
|
-
* 「macOS 会漏过来」加过一个顶层段过滤,那是把**建流窗口的补报**(建流前一刻的
|
|
1176
|
-
* 写入会被补进来一两条)误读成了嵌套事件。
|
|
1355
|
+
* **不传 `recursive`**;`persistent: false` 是说监听不吊住事件循环。**filename 一概
|
|
1356
|
+
* 不看**:它可能为 null,而非递归就是真的非递归,`objects/` 的海量写入到不了这里,
|
|
1357
|
+
* 没有需要按名字排除的东西。
|
|
1177
1358
|
*/
|
|
1178
1359
|
const w = watch(dir, { persistent: false }, () => schedule());
|
|
1179
1360
|
w.on("error", (cause) => {
|
|
1180
1361
|
/**
|
|
1181
|
-
* 出错的 watcher
|
|
1182
|
-
*
|
|
1183
|
-
* 活着吗」的唯一依据。虚高的那份不会报错,只是判据从此对着一个空壳返回
|
|
1184
|
-
* 「还活着」。
|
|
1362
|
+
* 出错的 watcher 已经不再送事件,留在数组里只会让 `size` 虚高——而 `size` 正是
|
|
1363
|
+
* 「`.git` 侧还活着吗」的唯一依据,虚高的那份不报错,只是对着空壳答「还活着」。
|
|
1185
1364
|
*/
|
|
1186
1365
|
const at = gitWatchers.indexOf(w);
|
|
1187
1366
|
if (at !== -1) gitWatchers.splice(at, 1);
|
|
@@ -1193,25 +1372,17 @@ function createWatcher(options) {
|
|
|
1193
1372
|
}
|
|
1194
1373
|
}
|
|
1195
1374
|
/**
|
|
1196
|
-
*
|
|
1197
|
-
*
|
|
1198
|
-
*
|
|
1199
|
-
*
|
|
1200
|
-
* `fs.inotify.max_user_watches` —— 之后整机所有依赖 inotify 的工具(包括用户自己的
|
|
1201
|
-
* 编辑器)开始报 ENOSPC。那是本工具唯一可能对用户机器造成的外部副作用。
|
|
1375
|
+
* 工作区侧,按档位分。**C 档一个递归 watch 都不建**:Node 在 Linux 上的递归实现是用户
|
|
1376
|
+
* 态遍历,对每个**普通文件**也注册一个 inotify watch,monorepo 下足以耗尽
|
|
1377
|
+
* `fs.inotify.max_user_watches`——之后整机依赖 inotify 的工具(含用户的编辑器)全报
|
|
1378
|
+
* ENOSPC,那是本工具唯一可能对用户机器造成的外部副作用。
|
|
1202
1379
|
*/
|
|
1203
1380
|
if (tier !== "C") try {
|
|
1204
1381
|
const handler = (_event, filename) => {
|
|
1205
1382
|
/**
|
|
1206
|
-
* **B
|
|
1207
|
-
*
|
|
1208
|
-
*
|
|
1209
|
-
*
|
|
1210
|
-
* A 档不在这里过滤:`ignore` 已经过滤过了(Linux 上还是**注册前跳过**,
|
|
1211
|
-
* 那才是配额问题的解法)。
|
|
1212
|
-
*
|
|
1213
|
-
* `filename` 可能为 null(Node 文档载明即便在支持的平台上也不保证提供),
|
|
1214
|
-
* 那时**放行**:漏刷一次比多刷一次糟得多。
|
|
1383
|
+
* **B 档的过滤必须在这里,也就是合并窗口之前**:放在窗口之后等于让 `node_modules`
|
|
1384
|
+
* 的写入噪声照样把窗口顶开。A 档由 `ignore` 过滤(Linux 上还是注册前跳过,那才是
|
|
1385
|
+
* 配额问题的解法)。`filename` 可能为 null,那时**放行**——漏刷比多刷糟得多。
|
|
1215
1386
|
*/
|
|
1216
1387
|
if (tier === "B" && typeof filename === "string" && isIgnored(filename)) return;
|
|
1217
1388
|
schedule();
|
|
@@ -1226,14 +1397,9 @@ function createWatcher(options) {
|
|
|
1226
1397
|
}, handler);
|
|
1227
1398
|
w.on("error", (cause) => {
|
|
1228
1399
|
/**
|
|
1229
|
-
* **必须显式 `close()
|
|
1230
|
-
*
|
|
1231
|
-
*
|
|
1232
|
-
* 但 Linux 的**用户态递归实现**不是:核对 `internal/fs/recursive_watch.js`,
|
|
1233
|
-
* `#watchFolder` 的 catch 里只有一句 `this.emit('error', …)`,已经注册的那一
|
|
1234
|
-
* 大批 inotify watch 全都还在,只有显式 `close()` 才放得掉。丢掉引用等于
|
|
1235
|
-
* 让它们活到进程结束 —— 而这条路径最典型的触发原因**正是 inotify 配额耗尽**,
|
|
1236
|
-
* 那时候还占着一堆配额不放,伤的是用户整机的其他工具。
|
|
1400
|
+
* **必须显式 `close()`,不能只把引用丢掉。** 原生 watcher 在 emit 之前就关了自己,
|
|
1401
|
+
* 但 Linux 的**用户态递归实现**只 emit 一个 error,已注册的那一大批 inotify watch
|
|
1402
|
+
* 全都还在——而这条路径最典型的触发原因正是配额耗尽,占着不放伤的是用户整机。
|
|
1237
1403
|
*/
|
|
1238
1404
|
w.close();
|
|
1239
1405
|
workspaceWatcher = null;
|
|
@@ -1271,10 +1437,8 @@ function createWatcher(options) {
|
|
|
1271
1437
|
//#endregion
|
|
1272
1438
|
//#region src/server/http/assets.ts
|
|
1273
1439
|
/**
|
|
1274
|
-
*
|
|
1275
|
-
*
|
|
1276
|
-
* 口径是**打包后**的位置:`dist/server/main.js` → `dist/web/`。本文件在 src 下的
|
|
1277
|
-
* 相对位置与此无关 —— 它只作为 tsdown 的输入存在,运行时永远是那个单文件 ESM。
|
|
1440
|
+
* 产物目录。口径是**打包后**的位置:`dist/server/main.js` → `dist/web/`。本文件在 src 下的
|
|
1441
|
+
* 相对位置与此无关——它只作为 tsdown 的输入存在,运行时永远是那个单文件 ESM。
|
|
1278
1442
|
*/
|
|
1279
1443
|
const WEB_ROOT = new URL("../web/", import.meta.url);
|
|
1280
1444
|
const ASSETS = /* @__PURE__ */ new Map([
|
|
@@ -1296,11 +1460,9 @@ const ASSETS = /* @__PURE__ */ new Map([
|
|
|
1296
1460
|
}]
|
|
1297
1461
|
]);
|
|
1298
1462
|
/**
|
|
1299
|
-
*
|
|
1300
|
-
*
|
|
1301
|
-
*
|
|
1302
|
-
* 都带 `Cache-Control: no-store`,浏览器那侧也不缓存 —— 不留的话每次刷新都要把三个
|
|
1303
|
-
* 文件重新读一遍磁盘。三份加起来两百多 KB,常驻的代价可以忽略。
|
|
1463
|
+
* 读过一次就留在内存里。`dist/web/` 在进程生命周期内不会变,而所有响应都带
|
|
1464
|
+
* `Cache-Control: no-store`、浏览器那侧也不缓存——不留的话每次刷新都要把三个文件重新读一
|
|
1465
|
+
* 遍磁盘。三份加起来两百多 KB,常驻的代价可以忽略。
|
|
1304
1466
|
*/
|
|
1305
1467
|
const cache = /* @__PURE__ */ new Map();
|
|
1306
1468
|
async function readAsset(asset) {
|
|
@@ -1315,7 +1477,7 @@ async function readAsset(asset) {
|
|
|
1315
1477
|
//#region src/server/http/idle.ts
|
|
1316
1478
|
/** 宽限期。取 30-60s 区间中值。 */
|
|
1317
1479
|
const IDLE_EXIT_MS = 45e3;
|
|
1318
|
-
/**
|
|
1480
|
+
/** 内部环境变量。不是给用户的开关,不进 `--help` 与 README。 */
|
|
1319
1481
|
const IDLE_ENV = "DIFFTAB_IDLE_MS";
|
|
1320
1482
|
var IdleConfigError = class extends Error {
|
|
1321
1483
|
constructor(message) {
|
|
@@ -1324,12 +1486,9 @@ var IdleConfigError = class extends Error {
|
|
|
1324
1486
|
}
|
|
1325
1487
|
};
|
|
1326
1488
|
/**
|
|
1327
|
-
*
|
|
1328
|
-
*
|
|
1329
|
-
*
|
|
1330
|
-
* 同一条理由:退回时手滑写错的那次照样启动成功、照样跑出一个看着合理的行为,
|
|
1331
|
-
* 于是「我验过空闲退出了」建立在一次根本没生效的强制指定上(那条用例会等满 45s
|
|
1332
|
-
* 而不是它以为的 800ms,表现为超时,读起来像产品坏了)。
|
|
1489
|
+
* 读宽限期。**取值不合法即启动失败,不退回默认的 45s**——与 `DIFFTAB_WATCH_TIER` 同一条
|
|
1490
|
+
* 理由:退回时手滑写错的那次照样启动成功、照样跑出一个看着合理的行为,于是「我验过空闲退出
|
|
1491
|
+
* 了」建立在一次根本没生效的强制指定上。
|
|
1333
1492
|
*/
|
|
1334
1493
|
function resolveIdleMs(env = process.env) {
|
|
1335
1494
|
const raw = env[IDLE_ENV];
|
|
@@ -1354,17 +1513,16 @@ function createIdleWatchdog(options) {
|
|
|
1354
1513
|
timer = setTimeout(() => {
|
|
1355
1514
|
timer = null;
|
|
1356
1515
|
/**
|
|
1357
|
-
*
|
|
1358
|
-
* 同一圈里完全可能刚接进一个 SSE
|
|
1359
|
-
*
|
|
1516
|
+
* **落地前再问一次**。定时器排队与回调执行之间隔着整个事件循环的这一圈,
|
|
1517
|
+
* 同一圈里完全可能刚接进一个 SSE 连接;而这个判断错了的症状是「用户正开着
|
|
1518
|
+
* 页面,进程自己没了」——比多活 45 秒糟得多。
|
|
1360
1519
|
*/
|
|
1361
1520
|
if (stopped || hasClients()) return;
|
|
1362
1521
|
onIdle();
|
|
1363
1522
|
}, idleMs);
|
|
1364
1523
|
/**
|
|
1365
|
-
*
|
|
1366
|
-
*
|
|
1367
|
-
* 进程多吊住一个宽限期,而那种残留最难看见。
|
|
1524
|
+
* **计时器不是进程活着的理由**,HTTP server 的监听句柄才是。不 unref 的话,close()
|
|
1525
|
+
* 之后漏了 stop() 就会把进程多吊住一个宽限期,而那种残留最难看见。
|
|
1368
1526
|
*/
|
|
1369
1527
|
timer.unref();
|
|
1370
1528
|
},
|
|
@@ -1381,55 +1539,45 @@ function createIdleWatchdog(options) {
|
|
|
1381
1539
|
const BIND_HOST = "127.0.0.1";
|
|
1382
1540
|
const ALLOWED_HOSTNAMES = ["127.0.0.1", "localhost"];
|
|
1383
1541
|
/**
|
|
1384
|
-
* cookie 名带上端口。
|
|
1385
|
-
*
|
|
1386
|
-
*
|
|
1387
|
-
* `127.0.0.1:<其他端口>` 的服务同样会收到我们的 cookie,反过来它设的同名 cookie
|
|
1388
|
-
* 也会盖掉我们的。名字里带端口,两个 difftab 实例(不同仓库、不同端口)才能
|
|
1389
|
-
* 在同一个浏览器里共存。
|
|
1542
|
+
* cookie 名带上端口。cookie 的作用域是 host 而非 origin,**不隔离端口**:同机另一个监听
|
|
1543
|
+
* `127.0.0.1:<其他端口>` 的服务同样会收到我们的 cookie,反过来它设的同名 cookie 也会盖掉
|
|
1544
|
+
* 我们的。名字里带端口,两个 difftab 实例(不同仓库、不同端口)才能在同一个浏览器里共存。
|
|
1390
1545
|
*/
|
|
1391
1546
|
function cookieName(port) {
|
|
1392
1547
|
return `difftab_token_${port}`;
|
|
1393
1548
|
}
|
|
1394
|
-
/** 会话 token
|
|
1549
|
+
/** 会话 token 的秘密部分。进程生命周期内持续有效,以支持页面刷新与多标签。 */
|
|
1395
1550
|
function createSecret() {
|
|
1396
1551
|
return randomBytes(32).toString("base64url");
|
|
1397
1552
|
}
|
|
1398
1553
|
/**
|
|
1399
|
-
* token 形如 `<port>.<secret
|
|
1400
|
-
*
|
|
1401
|
-
* 端口写进 token 本身,是为了让服务端校验时**一并绑定校验本次会话的端口**
|
|
1402
|
-
* cookie 的作用域是 host 而非 origin:它会泄漏给本机其他 localhost 服务,带上端口后
|
|
1403
|
-
* 那份 token 在别处无法复用。
|
|
1554
|
+
* token 形如 `<port>.<secret>`。端口写进 token 本身,是为了让服务端校验时**一并绑定本次
|
|
1555
|
+
* 会话的端口**——cookie 会泄漏给本机其他 localhost 服务,带上端口后它在别处无法复用。
|
|
1404
1556
|
*/
|
|
1405
1557
|
function composeToken(port, secret) {
|
|
1406
1558
|
return `${port}.${secret}`;
|
|
1407
1559
|
}
|
|
1408
1560
|
/**
|
|
1409
|
-
* 打印给用户、也拿去拉起浏览器的那个 URL
|
|
1410
|
-
*
|
|
1411
|
-
*
|
|
1412
|
-
* 同一个 URL 交给浏览器。两处各拼一遍的话,哪天 token 的落地方式变了
|
|
1413
|
-
* (换 query 名、换编码),复用那条路会拼出一个 403 的链接 —— 而它平时不走,
|
|
1414
|
-
* 谁也不会先注意到。
|
|
1561
|
+
* 打印给用户、也拿去拉起浏览器的那个 URL。**只此一处**:探活复用命中时,是**另一个进程**
|
|
1562
|
+
* 按注册表里的 port + token 重新拼出同一个 URL。两处各拼一遍的话,哪天 token 的落地方式变
|
|
1563
|
+
* 了(换 query 名、换编码),复用那条路会拼出一个 403 的链接——而它平时不走。
|
|
1415
1564
|
*/
|
|
1416
1565
|
function sessionUrl(port, token) {
|
|
1417
1566
|
return `http://${BIND_HOST}:${port}/?token=${encodeURIComponent(token)}`;
|
|
1418
1567
|
}
|
|
1419
1568
|
/**
|
|
1420
|
-
* 第 1
|
|
1421
|
-
*
|
|
1422
|
-
*
|
|
1569
|
+
* 第 1 道:**校验 `Host` 头**。这才是 DNS rebinding 的正面防御——token 挡不住
|
|
1570
|
+
* 同源判定本身,rebinding 的攻击页面把自己的域名重绑到 127.0.0.1 后,浏览器
|
|
1571
|
+
* 认为它与本服务同源,而它发出的请求 `Host` 是攻击者的域名。
|
|
1423
1572
|
*/
|
|
1424
1573
|
function isHostAllowed(host, port) {
|
|
1425
1574
|
if (!host) return false;
|
|
1426
1575
|
return ALLOWED_HOSTNAMES.some((name) => host === `${name}:${port}`);
|
|
1427
1576
|
}
|
|
1428
1577
|
/**
|
|
1429
|
-
* 第 2
|
|
1430
|
-
*
|
|
1431
|
-
*
|
|
1432
|
-
* 跨源重定向)是非空值,按不匹配处理 —— 那正是要挡的东西。
|
|
1578
|
+
* 第 2 道:**校验 `Origin`**。非空且不等于自身则拒;空是正常的(同源的导航与 GET fetch 本
|
|
1579
|
+
* 就不带 Origin)。`null`(沙箱化 iframe、跨源重定向)是非空值,按不匹配处理——那正是要挡
|
|
1580
|
+
* 的东西。
|
|
1433
1581
|
*/
|
|
1434
1582
|
function isOriginAllowed(origin, port) {
|
|
1435
1583
|
if (!origin) return true;
|
|
@@ -1446,7 +1594,7 @@ function readCookieToken(header, port) {
|
|
|
1446
1594
|
}
|
|
1447
1595
|
return null;
|
|
1448
1596
|
}
|
|
1449
|
-
/**
|
|
1597
|
+
/** 定长比较。长度不同直接判否——长度本身不是秘密。 */
|
|
1450
1598
|
function tokensMatch(presented, expected) {
|
|
1451
1599
|
if (!presented) return false;
|
|
1452
1600
|
const a = Buffer.from(presented, "utf8");
|
|
@@ -1455,12 +1603,9 @@ function tokensMatch(presented, expected) {
|
|
|
1455
1603
|
return timingSafeEqual(a, b);
|
|
1456
1604
|
}
|
|
1457
1605
|
/**
|
|
1458
|
-
* 第 3 道之外的公共响应头。
|
|
1459
|
-
*
|
|
1460
|
-
*
|
|
1461
|
-
* `'none'` 一并挡掉被 iframe 嵌套、`<base>` 改写相对 URL 与表单外发。
|
|
1462
|
-
* 不开 `'unsafe-inline'` 是构建链路顺带解锁的 —— 产物是独立的 .js / .css、
|
|
1463
|
-
* 页面无内联脚本,才有条件不开。
|
|
1606
|
+
* 第 3 道之外的公共响应头。CSP 的后三个指令**不回退到 `default-src`**,不显式写就等于没设:
|
|
1607
|
+
* `'none'` 一并挡掉被 iframe 嵌套、`<base>` 改写相对 URL 与表单外发。不开 `'unsafe-inline'`
|
|
1608
|
+
* 是构建链路顺带解锁的——产物是独立的 .js / .css、页面无内联脚本,才有条件不开。
|
|
1464
1609
|
*/
|
|
1465
1610
|
const SECURITY_HEADERS = {
|
|
1466
1611
|
"Cache-Control": "no-store",
|
|
@@ -1477,11 +1622,9 @@ const SECURITY_HEADERS = {
|
|
|
1477
1622
|
].join("; ")
|
|
1478
1623
|
};
|
|
1479
1624
|
/**
|
|
1480
|
-
* 把 token 落成 cookie 的 Set-Cookie
|
|
1481
|
-
*
|
|
1482
|
-
*
|
|
1483
|
-
* 历史、地址栏和日志中。不设 `Secure` —— 那会让 http://127.0.0.1
|
|
1484
|
-
* 上的 cookie 直接不生效。
|
|
1625
|
+
* 把 token 落成 cookie 的 Set-Cookie 值。`HttpOnly; SameSite=Strict` + 随后 302 掉 query,
|
|
1626
|
+
* 避免 token 长期滞留在浏览器历史、地址栏和日志中。不设 `Secure`——那会让
|
|
1627
|
+
* http://127.0.0.1 上的 cookie 直接不生效。
|
|
1485
1628
|
*/
|
|
1486
1629
|
function tokenCookie(port, token) {
|
|
1487
1630
|
return `${cookieName(port)}=${token}; Path=/; HttpOnly; SameSite=Strict`;
|
|
@@ -1490,22 +1633,17 @@ function tokenCookie(port, token) {
|
|
|
1490
1633
|
//#endregion
|
|
1491
1634
|
//#region src/server/shared/protocol.ts
|
|
1492
1635
|
/**
|
|
1493
|
-
* 服务端 SSE
|
|
1494
|
-
*
|
|
1495
|
-
*
|
|
1496
|
-
* 拿它当「对端还在」的判据(`web/state/events.ts` 的 `STALE_MS` 由它推出来)。两边
|
|
1497
|
-
* 各写一个 15000,改周期时只改一边不会报错、也没有任何用例会红 —— 前端的死连接
|
|
1498
|
-
* 判定就此静默失准,而症状只是「有时候刷不出来」。
|
|
1636
|
+
* 服务端 SSE 心跳周期(约 15s)。**它是协议的一部分,所以定在这里而不是 http/sse.ts**:
|
|
1637
|
+
* 后端拿它当发送周期,前端拿它当「对端还在」的判据。两边各写一个 15000,改周期时只改一边
|
|
1638
|
+
* 不会报错、也没有任何用例会红——前端的死连接判定就此静默失准,症状只是「有时候刷不出来」。
|
|
1499
1639
|
*/
|
|
1500
1640
|
const HEARTBEAT_MS = 15e3;
|
|
1501
1641
|
|
|
1502
1642
|
//#endregion
|
|
1503
1643
|
//#region src/server/http/sse.ts
|
|
1504
1644
|
/**
|
|
1505
|
-
* 一条 SSE
|
|
1506
|
-
*
|
|
1507
|
-
* `data` 一律是一行 JSON:多行正文要逐行加 `data: ` 前缀,而正文里一旦出现换行
|
|
1508
|
-
* (将来带上文件名之类)就会静默变成半条消息。`JSON.stringify` 保证不含裸换行。
|
|
1645
|
+
* 一条 SSE 消息的线格式。`data` 一律是一行 JSON:多行正文要逐行加 `data: ` 前缀,而正文里
|
|
1646
|
+
* 一旦出现换行就会静默变成半条消息,`JSON.stringify` 保证不含裸换行。
|
|
1509
1647
|
*/
|
|
1510
1648
|
function formatEvent(name, data) {
|
|
1511
1649
|
return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
@@ -1518,10 +1656,9 @@ function createSseChannel({ heartbeatMs = HEARTBEAT_MS, onChange } = {}) {
|
|
|
1518
1656
|
client.write(chunk);
|
|
1519
1657
|
} catch {
|
|
1520
1658
|
/**
|
|
1521
|
-
*
|
|
1522
|
-
* `ServerResponse.write()`
|
|
1523
|
-
*
|
|
1524
|
-
* 照样收得到。
|
|
1659
|
+
* **真正把死连接摘出去的不是这里**,是端点上的 `'close'` / `'error'` 监听器——
|
|
1660
|
+
* `ServerResponse.write()` 对已结束或已销毁的响应不会同步抛,失败是异步的。这层
|
|
1661
|
+
* catch 只保证一个客户端写炸了不会让这一轮广播半路夭折。
|
|
1525
1662
|
*/
|
|
1526
1663
|
clients.delete(client);
|
|
1527
1664
|
}
|
|
@@ -1554,10 +1691,8 @@ function createSseChannel({ heartbeatMs = HEARTBEAT_MS, onChange } = {}) {
|
|
|
1554
1691
|
//#endregion
|
|
1555
1692
|
//#region src/server/http/server.ts
|
|
1556
1693
|
/**
|
|
1557
|
-
*
|
|
1558
|
-
*
|
|
1559
|
-
* git 的 fatal 文本里经常带着完整仓库路径,原样回给页面就把本机目录结构泄漏进了
|
|
1560
|
-
* 一个任何本地页面都可能读到的响应体。
|
|
1694
|
+
* 错误信息不含绝对路径:git 的 fatal 文本经常带着完整仓库路径,原样回给页面就把本机
|
|
1695
|
+
* 目录结构泄漏进了一个任何本地页面都可能读到的响应体。
|
|
1561
1696
|
*/
|
|
1562
1697
|
function sanitizeMessage(message, repoRoot) {
|
|
1563
1698
|
const home = homedir();
|
|
@@ -1579,11 +1714,8 @@ function sendJson(res, status, body) {
|
|
|
1579
1714
|
}
|
|
1580
1715
|
async function startServer(repo, options = {}) {
|
|
1581
1716
|
/**
|
|
1582
|
-
*
|
|
1583
|
-
*
|
|
1584
|
-
* 早先只在兜底的 500 分支上过一次 sanitizeMessage,于是「响应体不含绝对路径」这条
|
|
1585
|
-
* 不变式实际挂在「以后新增的错误消息恰好都不带路径」上 —— 那不是不变式,是巧合。
|
|
1586
|
-
* 放在唯一出口上,新增分支自动被覆盖。
|
|
1717
|
+
* 出站错误一律经此发出,**sanitize 就做在这里**。只在兜底的 500 分支上做时,「响应体
|
|
1718
|
+
* 不含绝对路径」实际挂在「以后新增的消息恰好都不带路径」上——那不是不变式,是巧合。
|
|
1587
1719
|
*/
|
|
1588
1720
|
const sendError = (res, status, code, message) => {
|
|
1589
1721
|
sendJson(res, status, { error: {
|
|
@@ -1596,29 +1728,25 @@ async function startServer(repo, options = {}) {
|
|
|
1596
1728
|
let token = "";
|
|
1597
1729
|
let port = 0;
|
|
1598
1730
|
/**
|
|
1599
|
-
*
|
|
1600
|
-
*
|
|
1601
|
-
*
|
|
1602
|
-
* `DIFFTAB_WATCH_TIER` 写错时要在启动那一刻响亮地失败,而不是等到某次订阅。
|
|
1603
|
-
* 判定本身是纯计算(读 `process.versions.node` 与 `process.platform`),不落在
|
|
1604
|
-
* 300ms 冷启动预算上。
|
|
1731
|
+
* 档位在**启动时**定,不等第一个订阅者:`/api/state` 里就带着它,而
|
|
1732
|
+
* `DIFFTAB_WATCH_TIER` 写错时要在启动那一刻响亮地失败。判定是纯计算,不落在 300ms
|
|
1733
|
+
* 冷启动预算上。
|
|
1605
1734
|
*/
|
|
1606
1735
|
const tier = resolveTier();
|
|
1607
1736
|
const tierWarning = forcedTierWarning();
|
|
1608
1737
|
if (tierWarning) process.stderr.write(`difftab: ${tierWarning}\n`);
|
|
1609
1738
|
/**
|
|
1610
|
-
*
|
|
1611
|
-
*
|
|
1739
|
+
* 通道与空闲计时器互相接线:连接数一变(add / remove)就 `touch` 一次。
|
|
1740
|
+
* 端点那侧因此**不必记得**在断连时重新武装——见 sse.ts 的 `onChange`。
|
|
1612
1741
|
*/
|
|
1613
1742
|
const events = createSseChannel({ onChange: () => idle.touch() });
|
|
1614
1743
|
/**
|
|
1615
|
-
* 空闲退出的计时器。**判据是 SSE
|
|
1616
|
-
*
|
|
1617
|
-
*
|
|
1618
|
-
* 因此严格弱于「连接数为 0 持续 45s」,只会晚退不会早退。
|
|
1744
|
+
* 空闲退出的计时器。**判据是 SSE 连接数,但任何通过校验的请求都重置计时**——连接数
|
|
1745
|
+
* 为 0 也可能是「刚被探活复用、浏览器还在启动」或「页面活着但 SSE 被中间层回收了」。
|
|
1746
|
+
* 两者取并集,退出条件因此严格弱于「连接数为 0 持续 45s」,只会晚退不会早退。
|
|
1619
1747
|
*
|
|
1620
|
-
* `resolveIdleMs()`
|
|
1621
|
-
*
|
|
1748
|
+
* `resolveIdleMs()` 在这里(listen 之前)读环境变量:写错的取值要在启动那一刻失败,
|
|
1749
|
+
* 而不是等到 45 秒后什么都没发生。
|
|
1622
1750
|
*/
|
|
1623
1751
|
const idle = createIdleWatchdog({
|
|
1624
1752
|
idleMs: resolveIdleMs(),
|
|
@@ -1626,13 +1754,11 @@ async function startServer(repo, options = {}) {
|
|
|
1626
1754
|
onIdle: () => options.onIdle?.()
|
|
1627
1755
|
});
|
|
1628
1756
|
/**
|
|
1629
|
-
*
|
|
1757
|
+
* 监听**懒起**:第一个 SSE 订阅者到了才建,起了就一直留到关服务。
|
|
1630
1758
|
*
|
|
1631
|
-
*
|
|
1632
|
-
*
|
|
1633
|
-
*
|
|
1634
|
-
* 不随最后一个订阅者关掉,则是因为刷新页面 = 断开再连,那会让上面那趟遍历
|
|
1635
|
-
* 每刷新一次重来一遍;空闲着的原生 watch 本身开销接近零(资源占用项)。
|
|
1759
|
+
* 懒起是因为冷启动门禁量的是「监听成功并打印 URL」,而 A / B 档要在 repoRoot 上建递归
|
|
1760
|
+
* watch——Linux 上那是用户态遍历整棵树,大仓库里足以把 300ms 预算一口吃掉。不随最后
|
|
1761
|
+
* 一个订阅者关掉,是因为刷新页面 = 断开再连,那会让那趟遍历每刷新一次重来一遍。
|
|
1636
1762
|
*/
|
|
1637
1763
|
let watcher = null;
|
|
1638
1764
|
const ensureWatcher = () => {
|
|
@@ -1643,26 +1769,20 @@ async function startServer(repo, options = {}) {
|
|
|
1643
1769
|
tier,
|
|
1644
1770
|
onChange: () => events.send("change", {}),
|
|
1645
1771
|
/**
|
|
1646
|
-
* 轮询探针。**注入而不是让 watch/ 自己去调 git**:git 子进程只许出现在
|
|
1647
|
-
*
|
|
1648
|
-
*
|
|
1649
|
-
*
|
|
1650
|
-
* 拦住它的不是类型(签名只是 `() => Promise<string>`),是这个唯一的注入点
|
|
1651
|
-
* 加上冒烟里那条「C 档在已存在的未跟踪目录里新增文件要推出 change」。
|
|
1772
|
+
* 轮询探针。**注入而不是让 watch/ 自己去调 git**:git 子进程只许出现在 server/git,
|
|
1773
|
+
* 依赖方向里也没有 watch → git 这条边。传进去的是主查询本身(`readStatusRaw` 用的
|
|
1774
|
+
* 就是 `STATUS_ARGS`),于是「轮询与主查询逐字相同」在这里是**一处赋值**而不是两处
|
|
1775
|
+
* 各自维护的巧合。
|
|
1652
1776
|
*
|
|
1653
|
-
*
|
|
1654
|
-
*
|
|
1655
|
-
*
|
|
1656
|
-
*
|
|
1657
|
-
* 工作区」的操作(实际上只有 `rebase --quit` 这一类)才会让标注停在旧值上。
|
|
1658
|
-
* 不为它把探针拆成两个来源:那会让「轮询与主查询是同一条命令」不再是构造上的
|
|
1659
|
-
* 事实,而换来的是一个交集极窄的窗口。
|
|
1777
|
+
* **已知的覆盖边界**:`operation` 不来自 status 输出,这条探针看不见它。三档都在
|
|
1778
|
+
* `gitDir` 上建了非递归 watch,正常情况下照样推得出 change;只有**那条 watch 自己也
|
|
1779
|
+
* 失败、整体落到轮询**之后,一次只动 git 目录的操作(`rebase --quit` 一类)才会让
|
|
1780
|
+
* 标注停在旧值上。不为它把探针拆成两个来源。
|
|
1660
1781
|
*/
|
|
1661
1782
|
pollStatus: () => readStatusRaw(repo.root),
|
|
1662
1783
|
/**
|
|
1663
|
-
*
|
|
1664
|
-
*
|
|
1665
|
-
* 于是页面会一直标着「原生监听」直到下一次真的有文件变更。
|
|
1784
|
+
* 降级为轮询(兜底)。**推一个 `change` 是必需的**:`mode` 只在 `/api/state` 里,
|
|
1785
|
+
* 前端不重取就看不到降级,页面会一直标着「原生监听」直到下一次真的有文件变更。
|
|
1666
1786
|
*/
|
|
1667
1787
|
onDegrade: (cause) => {
|
|
1668
1788
|
process.stderr.write(`difftab: file watching degraded — ${sanitizeMessage(cause.message, repo.root)}\n`);
|
|
@@ -1671,11 +1791,8 @@ async function startServer(repo, options = {}) {
|
|
|
1671
1791
|
});
|
|
1672
1792
|
};
|
|
1673
1793
|
/**
|
|
1674
|
-
* 当前的监听状态(`WatchState`)
|
|
1675
|
-
*
|
|
1676
|
-
* **每次请求现算,不是启动时算一次**:降级发生在运行中,而算一次存起来的那份
|
|
1677
|
-
* 不会报错,只是从此永远说「原生监听」。监听懒起(见上),还没起来时按档位的
|
|
1678
|
-
* 既定形态答 —— C 档从第一份 `/api/state` 起就该是 `polling`。
|
|
1794
|
+
* 当前的监听状态(`WatchState`)。**每次请求现算**:降级发生在运行中,算一次存起来的
|
|
1795
|
+
* 那份不报错,只是从此永远说「原生监听」。监听懒起,还没起来时按档位的既定形态答。
|
|
1679
1796
|
*/
|
|
1680
1797
|
const currentWatchState = () => ({
|
|
1681
1798
|
mode: watcher?.mode ?? initialMode(tier),
|
|
@@ -1713,17 +1830,14 @@ async function startServer(repo, options = {}) {
|
|
|
1713
1830
|
return;
|
|
1714
1831
|
}
|
|
1715
1832
|
/**
|
|
1716
|
-
*
|
|
1717
|
-
*
|
|
1718
|
-
* 续命一个该退的进程 —— 而承诺是「不留后台常驻进程」。
|
|
1833
|
+
* 到这里才算「有人在」,**三道校验之前不算**(上面那个 302 分支同理)。放在函数开头
|
|
1834
|
+
* 更省事,代价是本机任何一个端口扫描器都能无限期续命一个该退的进程。
|
|
1719
1835
|
*/
|
|
1720
1836
|
idle.touch();
|
|
1721
1837
|
/**
|
|
1722
|
-
*
|
|
1723
|
-
*
|
|
1724
|
-
*
|
|
1725
|
-
* 见安全那节。副作用是上面那个 query token 分支也排在它前面:带合法
|
|
1726
|
-
* token 的非幂等请求会先被 302 换成 cookie 而不是 405。它已经过了三道校验,是取舍。
|
|
1838
|
+
* 只读工具不需要任何非幂等端点。**必须留在三道校验之后**,否则泄漏服务存在性。副作用
|
|
1839
|
+
* 是带合法 token 的非幂等请求会先被上面那个分支 302 换成 cookie 而不是 405——它已经
|
|
1840
|
+
* 过了三道校验,是取舍。
|
|
1727
1841
|
*/
|
|
1728
1842
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1729
1843
|
sendError(res, 405, "method-not-allowed", "only GET is supported");
|
|
@@ -1746,14 +1860,10 @@ async function startServer(repo, options = {}) {
|
|
|
1746
1860
|
return;
|
|
1747
1861
|
}
|
|
1748
1862
|
/**
|
|
1749
|
-
* 探活复用的应答。**消费者是下一个 CLI
|
|
1750
|
-
*
|
|
1751
|
-
*
|
|
1752
|
-
*
|
|
1753
|
-
* difftab 时,那边同样有一份合法 token —— 只不过不是我们记在注册表里的那个,
|
|
1754
|
-
* 于是它会 403、被判为陈旧,正确。真正需要这个正文的是相反的一侧:200 之后
|
|
1755
|
-
* 还要确认路径确实是我们这个仓库,否则「复用」会把用户带到别人的页面(
|
|
1756
|
-
* 排除 pid 判活也是同一条理由)。
|
|
1863
|
+
* 探活复用的应答。**消费者是下一个 CLI 进程,不是前端。** 它答的是「这个端口现在
|
|
1864
|
+
* 还是**这个仓库**的实例吗」:光有三道校验答不了——token 不匹配只证明「不是我们
|
|
1865
|
+
* 这一份会话」,而 200 之后还要确认路径确实是我们这个仓库,否则「复用」会把用户带到
|
|
1866
|
+
* 别人的页面(排除 pid 判活也是同一条理由)。
|
|
1757
1867
|
*/
|
|
1758
1868
|
case "/api/instance":
|
|
1759
1869
|
sendJson(res, 200, {
|
|
@@ -1775,9 +1885,26 @@ async function startServer(repo, options = {}) {
|
|
|
1775
1885
|
return;
|
|
1776
1886
|
}
|
|
1777
1887
|
/**
|
|
1778
|
-
*
|
|
1779
|
-
*
|
|
1888
|
+
* 文件浏览器的目录树,**一次只回一层**。`path` 缺省即仓库根——根是这个端点唯一一个
|
|
1889
|
+
* 天天要用的取值,让它必须显式传一个空串只是把 `?path=` 写进每一次调用。
|
|
1780
1890
|
*/
|
|
1891
|
+
case "/api/tree":
|
|
1892
|
+
sendJson(res, 200, await readTree(repo.root, url.searchParams.get("path") ?? ""));
|
|
1893
|
+
return;
|
|
1894
|
+
/**
|
|
1895
|
+
* 单个文件的只读内容。**`path` 必填**,与 `/api/tree` 刻意不同:树有一个自然的默认
|
|
1896
|
+
* 值(根),而「默认读哪个文件」不存在,缺省时静默给一份别的内容比 400 更糟。
|
|
1897
|
+
*/
|
|
1898
|
+
case "/api/file": {
|
|
1899
|
+
const path = url.searchParams.get("path");
|
|
1900
|
+
if (!path) {
|
|
1901
|
+
sendError(res, 400, "bad-request", "path is required");
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
sendJson(res, 200, await readFileContent(repo.root, path));
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
/** SSE。**三道校验在上面已经统一走过**,这里没有例外分支。 */
|
|
1781
1908
|
case "/api/events": {
|
|
1782
1909
|
const headers = {
|
|
1783
1910
|
...SECURITY_HEADERS,
|
|
@@ -1792,33 +1919,23 @@ async function startServer(repo, options = {}) {
|
|
|
1792
1919
|
res.writeHead(200, headers);
|
|
1793
1920
|
res.write(": connected\n\n");
|
|
1794
1921
|
/**
|
|
1795
|
-
* 两个监听器都必须挂在 `events.add`
|
|
1796
|
-
*
|
|
1797
|
-
* `
|
|
1798
|
-
*
|
|
1799
|
-
*
|
|
1800
|
-
* 是整个进程带着裸栈崩掉,而不是丢一条心跳。
|
|
1922
|
+
* 两个监听器都必须挂在 `events.add` **之前**。`close` 覆盖全部断开路径(关标签、
|
|
1923
|
+
* 刷新、对端被 kill);`error` 是长连接独有的一条——写向一条已经结束的响应,
|
|
1924
|
+
* `write()` 不同步抛,而是在 `res` 上**异步** emit `'error'`,而零监听器的
|
|
1925
|
+
* EventEmitter 收到 `'error'` 是整个进程带着裸栈崩掉。顺序则是因为 add 之后、挂上
|
|
1926
|
+
* 之前断开的话就再没人把它摘出去:心跳一直写向死响应,空闲退出也永远数不回 0。
|
|
1801
1927
|
*
|
|
1802
|
-
*
|
|
1803
|
-
*
|
|
1804
|
-
*/
|
|
1805
|
-
/**
|
|
1806
|
-
* 断开之后**必须再 touch 一次**:计时只在 `touch()` 里起,而这是「最后一个
|
|
1807
|
-
* 标签被关掉」在服务端的唯一形态。少了它,进程要等到下一个请求(而那正是
|
|
1808
|
-
* 没有人再发的东西)才想起来自己已经空了 —— 症状是关完浏览器留一个永久
|
|
1809
|
-
* 常驻进程,与那条验收项正相反。
|
|
1928
|
+
* 断开之后**必须再 touch 一次**(计时只在 `touch()` 里起):这是「最后一个标签被
|
|
1929
|
+
* 关掉」在服务端的唯一形态,少了它就是关完浏览器留一个永久常驻进程。
|
|
1810
1930
|
*/
|
|
1811
1931
|
res.on("close", () => events.remove(res));
|
|
1812
1932
|
res.on("error", () => events.remove(res));
|
|
1813
1933
|
events.add(res);
|
|
1814
1934
|
/**
|
|
1815
|
-
*
|
|
1816
|
-
*
|
|
1817
|
-
*
|
|
1818
|
-
*
|
|
1819
|
-
* 几百毫秒到数秒。留在这里的话,它占住的是整条事件循环 —— 页面此刻正并发
|
|
1820
|
-
* 发着 `/api/state` 与静态资源,那些请求会一起卡住,首屏因此变慢,
|
|
1821
|
-
* 而症状与「监听很慢」毫无相似之处。
|
|
1935
|
+
* **监听推到下一拍再起**,不在这个请求里同步建:Linux 上 A / B 档的递归 watch 是
|
|
1936
|
+
* 用户态的同步遍历,大仓库上要跑几百毫秒到数秒,留在这里占住的是整条事件循环——
|
|
1937
|
+
* 页面此刻正并发发着 `/api/state` 与静态资源,首屏因此一起卡住,而症状与「监听很
|
|
1938
|
+
* 慢」毫无相似之处。
|
|
1822
1939
|
*/
|
|
1823
1940
|
setImmediate(ensureWatcher);
|
|
1824
1941
|
return;
|
|
@@ -1832,7 +1949,7 @@ async function startServer(repo, options = {}) {
|
|
|
1832
1949
|
res.destroy();
|
|
1833
1950
|
return;
|
|
1834
1951
|
}
|
|
1835
|
-
if (cause instanceof
|
|
1952
|
+
if (cause instanceof WorktreeError) {
|
|
1836
1953
|
sendError(res, 400, cause.code, cause.message);
|
|
1837
1954
|
return;
|
|
1838
1955
|
}
|
|
@@ -1860,11 +1977,9 @@ async function startServer(repo, options = {}) {
|
|
|
1860
1977
|
port = address.port;
|
|
1861
1978
|
token = composeToken(port, secret);
|
|
1862
1979
|
/**
|
|
1863
|
-
*
|
|
1864
|
-
*
|
|
1865
|
-
*
|
|
1866
|
-
* (headless、无 `xdg-open`、`--no-open` 之后用户改了主意)变成永久常驻的后台进程。
|
|
1867
|
-
* 45 秒足够覆盖冷启动一个浏览器进程的 2-5s。
|
|
1980
|
+
* **宽限期从这一刻就开始计,不等第一个客户端**:等第一个客户端才起计时,会把「浏览器
|
|
1981
|
+
* 压根没拉起来」(headless、无 `xdg-open`、`--no-open` 之后改主意)整类情形变成永久
|
|
1982
|
+
* 常驻的后台进程。45 秒足够覆盖冷启动一个浏览器进程的 2-5s。
|
|
1868
1983
|
*/
|
|
1869
1984
|
idle.touch();
|
|
1870
1985
|
return {
|
|
@@ -1884,10 +1999,8 @@ async function startServer(repo, options = {}) {
|
|
|
1884
1999
|
//#endregion
|
|
1885
2000
|
//#region src/server/cli/browser.ts
|
|
1886
2001
|
/**
|
|
1887
|
-
* 平台 →
|
|
1888
|
-
*
|
|
1889
|
-
* Windows 那串里的空串不是笔误:它是 `start` 的窗口标题占位,少了它,带引号的 URL
|
|
1890
|
-
* 会被当作标题吞掉,浏览器根本不开。
|
|
2002
|
+
* 平台 → 命令的固定映射。**纯函数,便于测试逐条钉住**。Windows 那串里的空串不是笔误:
|
|
2003
|
+
* 它是 `start` 的窗口标题占位,少了它,带引号的 URL 会被当作标题吞掉,浏览器根本不开。
|
|
1891
2004
|
*/
|
|
1892
2005
|
function browserCommand(platform, url) {
|
|
1893
2006
|
if (platform === "darwin") return {
|
|
@@ -1909,18 +2022,15 @@ function browserCommand(platform, url) {
|
|
|
1909
2022
|
};
|
|
1910
2023
|
}
|
|
1911
2024
|
/**
|
|
1912
|
-
* CI
|
|
1913
|
-
*
|
|
1914
|
-
* 这不是「为开发便利而放宽的分支」—— 它不改变任何校验,只决定要不要多起一个进程。
|
|
2025
|
+
* CI 与冷启动测量里必须能关掉这个调用,否则每跑一次测试就弹一次浏览器。这不是「为开发
|
|
2026
|
+
* 便利而放宽的分支」——它不改变任何校验,只决定要不要多起一个进程。
|
|
1915
2027
|
*/
|
|
1916
2028
|
function browserDisabled(env = process.env) {
|
|
1917
2029
|
return Boolean(env.DIFFTAB_NO_OPEN);
|
|
1918
2030
|
}
|
|
1919
2031
|
/**
|
|
1920
|
-
*
|
|
1921
|
-
*
|
|
1922
|
-
* 失败(没有 `xdg-open`、headless 环境)只打印 URL 让用户自行访问,**不作为启动失败**。
|
|
1923
|
-
* URL 在调用本函数之前已经打印过了,所以这里什么都不用补。
|
|
2032
|
+
* 返回是否真的尝试了拉起。失败(没有 `xdg-open`、headless 环境)只打印 URL 让用户自行
|
|
2033
|
+
* 访问,**不作为启动失败**;URL 在调用本函数之前已经打印过了。
|
|
1924
2034
|
*/
|
|
1925
2035
|
function openBrowser(url) {
|
|
1926
2036
|
if (browserDisabled()) return false;
|
|
@@ -1941,28 +2051,22 @@ function openBrowser(url) {
|
|
|
1941
2051
|
//#endregion
|
|
1942
2052
|
//#region src/server/cli/registry.ts
|
|
1943
2053
|
/**
|
|
1944
|
-
*
|
|
1945
|
-
*
|
|
1946
|
-
*
|
|
1947
|
-
* `os.tmpdir()` 的权限因平台而异,Linux 上是 `/tmp`(1777,
|
|
1948
|
-
* 同机其他用户可读),所以这里再套一层每用户私有子目录,文件本身另有 0o600。
|
|
2054
|
+
* 注册表目录。**绝不能写进 `.git/` 或工作区**——那既污染 `git status`,也实质违背零写
|
|
2055
|
+
* 操作承诺。`os.tmpdir()` 的权限因平台而异(Linux 上 `/tmp` 是 1777、同机其他用户可读),
|
|
2056
|
+
* 所以这里再套一层每用户私有子目录,文件本身另有 0o600。
|
|
1949
2057
|
*/
|
|
1950
2058
|
function registryDir() {
|
|
1951
2059
|
return join(tmpdir(), "difftab");
|
|
1952
2060
|
}
|
|
1953
2061
|
/**
|
|
1954
|
-
*
|
|
1955
|
-
*
|
|
1956
|
-
*
|
|
1957
|
-
*
|
|
1958
|
-
*
|
|
1959
|
-
*
|
|
1960
|
-
*
|
|
1961
|
-
*
|
|
1962
|
-
* **探活比对仓库身份时用的也是这一份**(probe.ts):一侧是 `/api/instance` 返回的
|
|
1963
|
-
* `git rev-parse --show-toplevel`、另一侧是本进程的同一条命令,看着不可能不等 ——
|
|
1964
|
-
* 但 macOS 上 `/tmp` 与 `/private/tmp` 这类差异照样能让两个活着的实例互不相认,
|
|
1965
|
-
* 于是同一个仓库开出第二个进程。两处各写一份归一化,漂移是静默的。
|
|
2062
|
+
* 把仓库路径归一成注册表的键。写入侧给的是 `git rev-parse --show-toplevel`,读取侧(dev
|
|
2063
|
+
* proxy)给的是 `process.cwd()`——**两者指向同一个目录时字面量仍可能不同**,而 hash 只认
|
|
2064
|
+
* 字面量:Windows 上 git 给 `C:/Users/x/repo` 而 cwd 给 `C:\Users\x\repo`;macOS 上 cwd 经
|
|
2065
|
+
* 符号链接进来时是 `/var/...` 而 git 给 `/private/var/...`。不归一的话 dev proxy 永远找不
|
|
2066
|
+
* 到正在运行的后端,症状是「后端明明起着却说没找到」。
|
|
2067
|
+
*
|
|
2068
|
+
* **探活比对仓库身份时用的也是这一份**:两处各写一份归一化,漂移是静默的——症状是同一个
|
|
2069
|
+
* 仓库开出第二个进程。
|
|
1966
2070
|
*/
|
|
1967
2071
|
function normalizeRepoKey(repoRoot) {
|
|
1968
2072
|
const abs = resolve(repoRoot);
|
|
@@ -1972,16 +2076,14 @@ function normalizeRepoKey(repoRoot) {
|
|
|
1972
2076
|
return abs;
|
|
1973
2077
|
}
|
|
1974
2078
|
}
|
|
1975
|
-
/** 文件名用仓库绝对路径的 hash
|
|
2079
|
+
/** 文件名用仓库绝对路径的 hash,避免把路径本身暴露在一个全局可读的目录名里。 */
|
|
1976
2080
|
function registryPath(repoRoot) {
|
|
1977
2081
|
const hash = createHash("sha256").update(normalizeRepoKey(repoRoot)).digest("hex").slice(0, 32);
|
|
1978
2082
|
return join(registryDir(), `${hash}.json`);
|
|
1979
2083
|
}
|
|
1980
2084
|
/**
|
|
1981
|
-
*
|
|
1982
|
-
*
|
|
1983
|
-
* `0o600` 必须**配合 `O_EXCL` 在创建时**给出(`'wx'` + mode),而不是先建后 chmod ——
|
|
1984
|
-
* 后者留下一个竞态窗口,窗口里文件是可读的,而它存着本次会话的 token。
|
|
2085
|
+
* 写入注册表。`0o600` 必须**配合 `O_EXCL` 在创建时**给出(`'wx'` + mode),而不是先建后
|
|
2086
|
+
* chmod——后者留下一个竞态窗口,窗口里文件是可读的,而它存着本次会话的 token。
|
|
1985
2087
|
*/
|
|
1986
2088
|
function writeExclusive(path, payload) {
|
|
1987
2089
|
const fd = openSync(path, "wx", 384);
|
|
@@ -2003,13 +2105,10 @@ function writeRegistry(entry) {
|
|
|
2003
2105
|
} catch (cause) {
|
|
2004
2106
|
if (cause.code !== "EEXIST") throw cause;
|
|
2005
2107
|
/**
|
|
2006
|
-
*
|
|
2007
|
-
*
|
|
2008
|
-
*
|
|
2009
|
-
*
|
|
2010
|
-
* 显式承认「我在覆盖别人的条目」。而那条 `0o600` 必须在创建时给出的要求
|
|
2011
|
-
* 也只有 `'wx'` 满足 —— `'w'` 对已存在的文件根本不套用 mode,一次遗留的
|
|
2012
|
-
* 0644 条目会被原样沿用,里面躺着本次会话的 token。
|
|
2108
|
+
* 走到这里说明**探活已经判过它是陈旧的**(start.ts 命中就不会再启动,更不会写注册
|
|
2109
|
+
* 表),所以覆盖是对的。保留 `O_EXCL` + 显式 unlink 而不是改成 `'w'`:那条「`0o600`
|
|
2110
|
+
* 必须在创建时给出」只有 `'wx'` 满足——`'w'` 对已存在的文件根本不套用 mode,一次遗留
|
|
2111
|
+
* 的 0644 条目会被原样沿用,里面躺着本次会话的 token。
|
|
2013
2112
|
*/
|
|
2014
2113
|
unlinkSync(path);
|
|
2015
2114
|
writeExclusive(path, payload);
|
|
@@ -2017,11 +2116,9 @@ function writeRegistry(entry) {
|
|
|
2017
2116
|
return path;
|
|
2018
2117
|
}
|
|
2019
2118
|
/**
|
|
2020
|
-
* 读取注册表。dev proxy
|
|
2021
|
-
*
|
|
2022
|
-
*
|
|
2023
|
-
* **「这条目还能用吗」只在这里判一次**:端口得是一个真能连的端口号、token 不能是
|
|
2024
|
-
* 空串。放在消费侧各判各的话,两个消费者迟早对「可用」有两套定义,而分歧的表现是
|
|
2119
|
+
* 读取注册表。dev proxy(见 vite.config.ts)与探活共用;读不到、或者读到一条用不了的条目,
|
|
2120
|
+
* 一律返回 null。**「这条目还能用吗」只在这里判一次**:端口得是一个真能连的端口号、token
|
|
2121
|
+
* 不能是空串。放在消费侧各判各的话,两个消费者迟早对「可用」有两套定义,而分歧的表现是
|
|
2025
2122
|
* dev proxy 与探活对同一条陈旧条目给出不同结论。
|
|
2026
2123
|
*/
|
|
2027
2124
|
function readRegistry(repoRoot) {
|
|
@@ -2035,7 +2132,7 @@ function readRegistry(repoRoot) {
|
|
|
2035
2132
|
return null;
|
|
2036
2133
|
}
|
|
2037
2134
|
}
|
|
2038
|
-
/**
|
|
2135
|
+
/** 退出时清理。只删自己写的那一条,免得把另一个实例的条目带走。 */
|
|
2039
2136
|
function removeRegistry(repoRoot) {
|
|
2040
2137
|
const path = registryPath(repoRoot);
|
|
2041
2138
|
let raw;
|
|
@@ -2057,28 +2154,21 @@ function removeRegistry(repoRoot) {
|
|
|
2057
2154
|
//#endregion
|
|
2058
2155
|
//#region src/server/cli/probe.ts
|
|
2059
2156
|
/**
|
|
2060
|
-
*
|
|
2061
|
-
*
|
|
2062
|
-
*
|
|
2063
|
-
*
|
|
2064
|
-
* 那件事。反过来这 1.5s 平时根本走不到:注册表不存在时压根不探(常态,冷启动门禁
|
|
2065
|
-
* 不受影响),端口已死时 localhost 上的 `ECONNREFUSED` 是立即返回的。
|
|
2157
|
+
* 探活超时。**不取更短**:被探的实例可能正卡在那趟用户态递归遍历里(Linux 大仓库上几百
|
|
2158
|
+
* 毫秒到数秒),超时过短的代价不是慢一点,而是**给同一个仓库起了第二个进程**。反过来这
|
|
2159
|
+
* 1.5s 平时根本走不到——注册表不存在时压根不探,端口已死时 localhost 上的
|
|
2160
|
+
* `ECONNREFUSED` 是立即返回的。
|
|
2066
2161
|
*/
|
|
2067
2162
|
const PROBE_TIMEOUT_MS = 1500;
|
|
2068
2163
|
/**
|
|
2069
|
-
*
|
|
2070
|
-
*
|
|
2071
|
-
* 端口可能已经归了一个完全无关的服务,而它的 `/api/instance` 可以是任何东西 ——
|
|
2072
|
-
* 一个流式响应会让下面那句 `body +=` 一直涨下去。上限之内,一份真正的
|
|
2073
|
-
* `InstanceInfo` 绰绰有余。
|
|
2164
|
+
* 正文读到这么多还没完就放弃:端口可能已经归了一个完全无关的服务,而它的 `/api/instance`
|
|
2165
|
+
* 可以是任何东西——一个流式响应会让下面那句 `body +=` 一直涨下去。
|
|
2074
2166
|
*/
|
|
2075
2167
|
const MAX_BODY_BYTES = 65536;
|
|
2076
2168
|
/**
|
|
2077
|
-
* 向记录的端口要一份 `InstanceInfo`。任何不顺利一律返回 null
|
|
2078
|
-
*
|
|
2079
|
-
*
|
|
2080
|
-
* 那也正是「端口已经归了另一个仓库的 difftab」这种情形的判据:它那份 token
|
|
2081
|
-
* 不是我们记下的这个,于是 403、判陈旧,正确。
|
|
2169
|
+
* 向记录的端口要一份 `InstanceInfo`。任何不顺利一律返回 null(= 按陈旧处理)。**带上
|
|
2170
|
+
* token 与合规的 `Host`**:三道校验一视同仁,探活不是例外——那也正是「端口已经归了另一个
|
|
2171
|
+
* 仓库的 difftab」这种情形的判据:它那份 token 不是我们记下的这个,于是 403、判陈旧。
|
|
2082
2172
|
*/
|
|
2083
2173
|
function requestInstance(entry, timeoutMs) {
|
|
2084
2174
|
return new Promise((resolvePromise) => {
|
|
@@ -2097,16 +2187,14 @@ function requestInstance(entry, timeoutMs) {
|
|
|
2097
2187
|
return;
|
|
2098
2188
|
}
|
|
2099
2189
|
/**
|
|
2100
|
-
*
|
|
2101
|
-
*
|
|
2102
|
-
*
|
|
2103
|
-
* `'
|
|
2104
|
-
*
|
|
2105
|
-
* 不到 `req.on('error')`,**这个 Promise 永远不 settle**,`start.ts` 里那句
|
|
2106
|
-
* `await findLiveInstance()` 就把启动整个吊死,没有任何输出。
|
|
2190
|
+
* **响应头到手之后,`req` 就不再是错误的出口了**:此后 `req.destroy()` 与对端中途
|
|
2191
|
+
* 断开都只落在 `res` 上(`'aborted'` 随后 `'error': ECONNRESET`),而
|
|
2192
|
+
* `IncomingMessage` 会把没人听的 `'error'` 自己吞掉——于是下面那两处 `destroy()`
|
|
2193
|
+
* (超时、正文过大)既走不到 `'end'`、也走不到 `req.on('error')`,**这个 Promise
|
|
2194
|
+
* 永远不 settle**,`start.ts` 里那句 `await findLiveInstance()` 把启动整个吊死。
|
|
2107
2195
|
*
|
|
2108
|
-
* `'close'` 是 `res`
|
|
2109
|
-
*
|
|
2196
|
+
* `'close'` 是 `res` 上唯一覆盖全部结局的事件;正常读完时它排在 `'end'` 之后,那时
|
|
2197
|
+
* 早已 resolve 过,再 resolve 一次没有作用(首次为准)。
|
|
2110
2198
|
*/
|
|
2111
2199
|
res.on("close", () => resolvePromise(null));
|
|
2112
2200
|
let body = "";
|
|
@@ -2136,13 +2224,10 @@ function requestInstance(entry, timeoutMs) {
|
|
|
2136
2224
|
});
|
|
2137
2225
|
req.on("error", () => resolvePromise(null));
|
|
2138
2226
|
/**
|
|
2139
|
-
* 超时用 `setTimeout` 而不是 `req.setTimeout
|
|
2140
|
-
*
|
|
2141
|
-
*
|
|
2142
|
-
*
|
|
2143
|
-
* **`destroy()` 之后必须自己 resolve**:响应头已经到手时,这一下的错误落在 `res`
|
|
2144
|
-
* 上而不是 `req` 上(见上面那段),光 destroy 等于把超时做成了一个不生效的摆设 ——
|
|
2145
|
-
* 而症状是启动整个吊死,恰好比没有超时更难看出来。
|
|
2227
|
+
* 超时用 `setTimeout` 而不是 `req.setTimeout`:后者只管 **socket 空闲**,一个每 200ms
|
|
2228
|
+
* 吐一个字节的第三方服务能让它永远不触发。**`destroy()` 之后必须自己 resolve**——
|
|
2229
|
+
* 响应头已经到手时,这一下的错误落在 `res` 上而不是 `req` 上,光 destroy 等于把超时做
|
|
2230
|
+
* 成了一个不生效的摆设,而症状是启动整个吊死,恰好比没有超时更难看出来。
|
|
2146
2231
|
*/
|
|
2147
2232
|
const timer = setTimeout(() => {
|
|
2148
2233
|
req.destroy();
|
|
@@ -2153,10 +2238,9 @@ function requestInstance(entry, timeoutMs) {
|
|
|
2153
2238
|
});
|
|
2154
2239
|
}
|
|
2155
2240
|
/**
|
|
2156
|
-
*
|
|
2157
|
-
*
|
|
2158
|
-
*
|
|
2159
|
-
* ——它退出时按 pid 只删自己的那条(registry.ts),我们改过的内容会让它留下垃圾。
|
|
2241
|
+
* 这个仓库已经有活着的实例吗?有就返回它的 URL,没有就 null。命中之后调用方**不得碰注册
|
|
2242
|
+
* 表**:那条目是另一个进程写的,连「顺手更新一下」都不行——它退出时按 pid 只删自己的那
|
|
2243
|
+
* 条,我们改过的内容会让它留下垃圾。
|
|
2160
2244
|
*/
|
|
2161
2245
|
async function findLiveInstance(repoRoot, { timeoutMs = PROBE_TIMEOUT_MS } = {}) {
|
|
2162
2246
|
const entry = readRegistry(repoRoot);
|
|
@@ -2164,11 +2248,9 @@ async function findLiveInstance(repoRoot, { timeoutMs = PROBE_TIMEOUT_MS } = {})
|
|
|
2164
2248
|
const info = await requestInstance(entry, timeoutMs);
|
|
2165
2249
|
if (!info) return null;
|
|
2166
2250
|
/**
|
|
2167
|
-
* **200
|
|
2168
|
-
*
|
|
2169
|
-
*
|
|
2170
|
-
* 它恰好也认得这份 token 的概率不高,但代价是把用户带到别人的页面,而这类判断
|
|
2171
|
-
* 错了不会有任何东西报错。归一化复用注册表键那一份实现,理由记在那边。
|
|
2251
|
+
* **200 还不够,路径也得对得上**:这一条挡的是「注册表条目还在,而那个端口已经被系统
|
|
2252
|
+
* 回收给了别的东西」——它恰好也认得这份 token 的概率不高,但代价是把用户带到别人的页面,
|
|
2253
|
+
* 而这类判断错了不会有任何东西报错。归一化复用注册表键那一份实现。
|
|
2172
2254
|
*/
|
|
2173
2255
|
if (normalizeRepoKey(info.repoRoot) !== normalizeRepoKey(repoRoot)) return null;
|
|
2174
2256
|
return {
|
|
@@ -2181,12 +2263,10 @@ async function findLiveInstance(repoRoot, { timeoutMs = PROBE_TIMEOUT_MS } = {})
|
|
|
2181
2263
|
//#region src/server/cli/start.ts
|
|
2182
2264
|
async function start(options) {
|
|
2183
2265
|
/**
|
|
2184
|
-
*
|
|
2185
|
-
*
|
|
2186
|
-
*
|
|
2187
|
-
*
|
|
2188
|
-
* (加前缀、换写法)会在复用那条上静默漂走,而 `scripts/bench-startup.mjs` 与
|
|
2189
|
-
* 冒烟套件的 ready 判据都压在这一行上。
|
|
2266
|
+
* 「可以看了」的唯一出口:**stdout 的第一行是且只是 URL**,随后一行说明,最后拉起浏览器。
|
|
2267
|
+
* 复用与新起两条路各写一遍的话,只有新起那条天天在跑——改了第一行的形状(加前缀、换写
|
|
2268
|
+
* 法)会在复用那条上静默漂走,而 `scripts/bench-startup.mjs` 与冒烟套件的 ready 判据都
|
|
2269
|
+
* 压在这一行上。
|
|
2190
2270
|
*/
|
|
2191
2271
|
function announce(url, note) {
|
|
2192
2272
|
process.stdout.write(`${url}\n`);
|
|
@@ -2195,11 +2275,9 @@ async function start(options) {
|
|
|
2195
2275
|
}
|
|
2196
2276
|
const repo = await locateRepo(options.cwd);
|
|
2197
2277
|
/**
|
|
2198
|
-
*
|
|
2199
|
-
*
|
|
2200
|
-
*
|
|
2201
|
-
* 不写注册表(那条目属于对面那个进程)、不装信号与 exit 处理器(装了会在本进程
|
|
2202
|
-
* 退出时替对面清理掉它的条目,虽然 removeRegistry 按 pid 又挡了一道)。
|
|
2278
|
+
* **同一个仓库已经有实例在跑,就把用户送去那一个**。判活是 HTTP 探活而不是 pid。命中这
|
|
2279
|
+
* 条路之后**什么都不写**:不写注册表(那条目属于对面那个进程)、不装信号与 exit 处理器
|
|
2280
|
+
* (装了会在本进程退出时替对面清理掉它的条目)。
|
|
2203
2281
|
*/
|
|
2204
2282
|
const existing = await findLiveInstance(repo.root);
|
|
2205
2283
|
if (existing) {
|
|
@@ -2208,8 +2286,8 @@ async function start(options) {
|
|
|
2208
2286
|
}
|
|
2209
2287
|
const server = await startServer(repo, {
|
|
2210
2288
|
/**
|
|
2211
|
-
*
|
|
2212
|
-
*
|
|
2289
|
+
* 空闲宽限期走满。**「怎么退」归这一层**:http/ 那边只知道
|
|
2290
|
+
* 「没人了」,退出码、提示语、以及退出前要不要先 close 都是启动流程的事。
|
|
2213
2291
|
*/
|
|
2214
2292
|
onIdle: () => stop(0, "difftab: no tabs left — exiting.\n") });
|
|
2215
2293
|
writeRegistry({
|
|
@@ -2221,23 +2299,20 @@ onIdle: () => stop(0, "difftab: no tabs left — exiting.\n") });
|
|
|
2221
2299
|
});
|
|
2222
2300
|
let closing = false;
|
|
2223
2301
|
/**
|
|
2224
|
-
*
|
|
2225
|
-
*
|
|
2226
|
-
* 已经 destroy 的 socket。
|
|
2302
|
+
* 唯一的退出路径。信号与空闲退出共用它——两者完全可能撞在一起(Ctrl+C 恰好落在宽限期
|
|
2303
|
+
* 走满的同一拍),各写一份的话 `server.close()` 会跑两次,第二次撞上已经 destroy 的 socket。
|
|
2227
2304
|
*/
|
|
2228
2305
|
const stop = (code, note) => {
|
|
2229
2306
|
if (closing) return;
|
|
2230
2307
|
closing = true;
|
|
2231
2308
|
/**
|
|
2232
|
-
* **`writeSync` 而不是 `process.stdout.write
|
|
2233
|
-
*
|
|
2234
|
-
*
|
|
2235
|
-
* kill 的」的判据,丢了之后症状是 stdout 里什么都没有。
|
|
2309
|
+
* **`writeSync` 而不是 `process.stdout.write`**:后者写到**管道**时在 Windows 上是异步
|
|
2310
|
+
* 的,紧跟着的 `process.exit()` 会把还在缓冲区里的内容整条丢掉(与 main.ts 那条报错同
|
|
2311
|
+
* 一个规避手法),而这句提示正是自动化验证「它是自己走的、不是被 kill 的」的判据。
|
|
2236
2312
|
*
|
|
2237
|
-
*
|
|
2238
|
-
*
|
|
2239
|
-
*
|
|
2240
|
-
* 退出 —— 而这条路承诺的是干净的 0。提示没打出去不是错误,是没人在听。
|
|
2313
|
+
* **而它得能失败**:读端已经走了(`difftab --no-open | head -1`)时 `writeSync` 抛
|
|
2314
|
+
* EPIPE,抛在这个回调里时 `closing` 已经合上,于是 `server.close()` 不再执行,进程带着
|
|
2315
|
+
* 一屏 Node 栈以 1 退出——而这条路承诺的是干净的 0。
|
|
2241
2316
|
*/
|
|
2242
2317
|
if (note) try {
|
|
2243
2318
|
writeSync(1, note);
|
|
@@ -2255,18 +2330,15 @@ onIdle: () => stop(0, "difftab: no tabs left — exiting.\n") });
|
|
|
2255
2330
|
//#endregion
|
|
2256
2331
|
//#region src/server/main.ts
|
|
2257
2332
|
/**
|
|
2258
|
-
* 版本号只有 package.json
|
|
2259
|
-
*
|
|
2260
|
-
*
|
|
2261
|
-
* 静默漂移(`--version` 报旧号,没有任何东西会失败);构建期注入则让 `dist/` 与
|
|
2262
|
-
* 源码对不上、调试时更难看清。运行时读一次的代价只落在 `--version` 这条路径上。
|
|
2263
|
-
* dist/server/main.js → 包根的 package.json;`files` 之外 npm 总会带上它。
|
|
2333
|
+
* 版本号只有 package.json 一个来源。不硬编码在这里,也不在构建期 define 进去:硬编码会在
|
|
2334
|
+
* 第一次发版改号时静默漂移(`--version` 报旧号,没有任何东西会失败);构建期注入则让
|
|
2335
|
+
* `dist/` 与源码对不上。运行时读一次的代价只落在 `--version` 这条路径上。
|
|
2264
2336
|
*/
|
|
2265
2337
|
function readVersion() {
|
|
2266
2338
|
const manifestPath = fileURLToPath(new URL("../../package.json", import.meta.url));
|
|
2267
2339
|
return JSON.parse(readFileSync(manifestPath, "utf8")).version;
|
|
2268
2340
|
}
|
|
2269
|
-
/**
|
|
2341
|
+
/** 一句话友好报错,不是 Node 异常栈(启动前置检查)。 */
|
|
2270
2342
|
function fail(message) {
|
|
2271
2343
|
try {
|
|
2272
2344
|
writeSync(2, `difftab: ${message}\n`);
|
|
@@ -2274,16 +2346,12 @@ function fail(message) {
|
|
|
2274
2346
|
process.exit(1);
|
|
2275
2347
|
}
|
|
2276
2348
|
/**
|
|
2277
|
-
*
|
|
2278
|
-
*
|
|
2279
|
-
*
|
|
2280
|
-
* 失败因此以一个 `'error'` 事件到达 —— 零监听器的流收到 `'error'` 就是整个进程带着
|
|
2281
|
-
* 裸栈以 1 退出。已在 CI 的 windows × Node 24 档实测到:`head` 一退,紧接着那句
|
|
2282
|
-
* 「read-only view of this repository」就把服务打死了,而 macOS / Linux 上同一条路
|
|
2283
|
-
* 一声不响。
|
|
2349
|
+
* 读端先走了不算错误(`difftab --no-open | head -1`:用户只想要那行 URL)。之后每一次写都以
|
|
2350
|
+
* EPIPE 失败,而**在 Windows 上管道写是异步的**(POSIX 上同步),失败因此以一个 `'error'`
|
|
2351
|
+
* 事件到达——零监听器的流收到 `'error'` 就是整个进程带着裸栈以 1 退出。
|
|
2284
2352
|
*
|
|
2285
|
-
*
|
|
2286
|
-
*
|
|
2353
|
+
* 服务本身没坏(浏览器照常用得上),所以这里只是把 EPIPE 咽掉。**非 EPIPE 的错误照旧抛**:
|
|
2354
|
+
* 那是真出事了,不该借这条路一起被吞掉。
|
|
2287
2355
|
*/
|
|
2288
2356
|
function ignoreBrokenPipe(stream) {
|
|
2289
2357
|
stream.on("error", (cause) => {
|