difftab 0.5.1 → 0.5.3
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 +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/server/main.js +585 -71
- package/dist/web/app.css +1 -1
- package/dist/web/app.js +19 -17
- package/package.json +2 -10
package/dist/server/main.js
CHANGED
|
@@ -75,13 +75,48 @@ const GLOBAL_CONFIG = ["-c", "core.quotePath=false"];
|
|
|
75
75
|
* 模式,而我们的路径全部来自 URL query——`path=*` 会让 `git diff HEAD -- '*'` 回一份
|
|
76
76
|
* **整仓 diff**,正是红线明令禁止、会把浏览器主线程冻上数十秒的那件事;而一个真实存在、
|
|
77
77
|
* 名字里带 `*` 的文件同样会匹配到别人身上,页面在 A 的标题下显示 B 的补丁。
|
|
78
|
+
*
|
|
79
|
+
* **`GIT_NO_LAZY_FETCH=1` 同样是只读承诺的一部分**:partial clone(`--filter=blob:none`)里缺的
|
|
80
|
+
* 对象,git 默认会当场从 promisor remote 取回来并写进 `.git/objects`——任何一条要读 blob 的命令
|
|
81
|
+
* (`diff`、`cat-file`)都会触发,而提交历史正是去读那些从没检出过的旧 blob。关掉之后缺对象就是
|
|
82
|
+
* 一次非零退出(实测 128:`could not fetch … from promisor remote`),不联网、不写库。它是
|
|
83
|
+
* git 2.44 才有的变量,更老的 git 不认——那一档的防线在 `history.ts`(partial clone 下直接拒)。
|
|
78
84
|
*/
|
|
79
85
|
const GIT_ENV = {
|
|
80
86
|
GIT_OPTIONAL_LOCKS: "0",
|
|
81
87
|
GIT_TERMINAL_PROMPT: "0",
|
|
82
|
-
GIT_LITERAL_PATHSPECS: "1"
|
|
88
|
+
GIT_LITERAL_PATHSPECS: "1",
|
|
89
|
+
GIT_NO_LAZY_FETCH: "1"
|
|
83
90
|
};
|
|
84
91
|
/**
|
|
92
|
+
* **每一条 `git diff` 都紧跟在子命令之后带上这两个开关**,由 `runGitRaw` 注入,不留给调用点挑:
|
|
93
|
+
* - `--no-textconv`:`.gitattributes` 里 `diff=<驱动>` + `diff.<驱动>.textconv` 会让补丁那一形态
|
|
94
|
+
* 对两侧各起一次外部程序,配了 `cachetextconv` 时还把结果写进 `refs/notes/textconv/<驱动>`
|
|
95
|
+
* ——实测补丁形态(工作区 diff 与提交 diff 都是)会写,`--numstat` / `--name-status` 不写,加上
|
|
96
|
+
* 本开关后一条都不写。numstat 那几条也带:二进制与行数的判定得与补丁看的是同一份内容;
|
|
97
|
+
* - `--no-ext-diff`:`diff.external` / `GIT_EXTERNAL_DIFF` 同理是一个外部程序。
|
|
98
|
+
*
|
|
99
|
+
* 代价是配了 textconv 的文件(`.docx`、加密文件)按原始字节比——二进制就说二进制。冒烟逐条
|
|
100
|
+
* 断言 `diff` 的 argv 里两者都在。
|
|
101
|
+
*/
|
|
102
|
+
const DIFF_GUARDS = ["--no-ext-diff", "--no-textconv"];
|
|
103
|
+
/** `-c` 全局选项排在子命令之前,`diff` 的两道外部程序开关紧跟在子命令之后。 */
|
|
104
|
+
function argvOf(args) {
|
|
105
|
+
const [subcommand, ...rest] = args;
|
|
106
|
+
return subcommand === "diff" ? [
|
|
107
|
+
...GLOBAL_CONFIG,
|
|
108
|
+
subcommand,
|
|
109
|
+
...DIFF_GUARDS,
|
|
110
|
+
...rest
|
|
111
|
+
] : [...GLOBAL_CONFIG, ...args];
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 缺对象的那几种报法(`GIT_NO_LAZY_FETCH` 之下的 partial clone):实测 `could not fetch … from
|
|
115
|
+
* promisor remote`,前面还有一行 `lazy fetching disabled`。**在这里分类一次**,而不是让每个调用点
|
|
116
|
+
* 各自记得 catch——漏 catch 的那条路会把「内容不在本地」说成「路径不存在」或一条 500。
|
|
117
|
+
*/
|
|
118
|
+
const MISSING_OBJECT = /promisor remote|lazy fetching disabled/;
|
|
119
|
+
/**
|
|
85
120
|
* stdout 的**兜底**上限:没有它,一个几百 MB 的文件的 diff 会被整个读进内存,而这条路径
|
|
86
121
|
* 上没有任何东西会先失败。产品语义那道 5MB 闸不在这里,由调用方按次传 `maxStdoutBytes`
|
|
87
122
|
*——**能不能渲染取决于补丁多大,而不是文件多大**,一个 6MB 的数据文件改一行照样该看得见。
|
|
@@ -111,7 +146,7 @@ var GitError = class extends Error {
|
|
|
111
146
|
function runGitRaw(args, cwd, options = {}) {
|
|
112
147
|
const limit = Math.min(options.maxStdoutBytes ?? MAX_STDOUT_BYTES, MAX_STDOUT_BYTES);
|
|
113
148
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
114
|
-
const argv =
|
|
149
|
+
const argv = argvOf(args);
|
|
115
150
|
const child = spawn("git", argv, {
|
|
116
151
|
cwd,
|
|
117
152
|
env: {
|
|
@@ -166,6 +201,10 @@ function runGitRaw(args, cwd, options = {}) {
|
|
|
166
201
|
rejectPromise(new GitError("overflow", argv, stderr, code));
|
|
167
202
|
return;
|
|
168
203
|
}
|
|
204
|
+
if (code !== 0 && MISSING_OBJECT.test(stderr)) {
|
|
205
|
+
rejectPromise(new GitError("missing-object", argv, stderr, code));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
169
208
|
resolvePromise({
|
|
170
209
|
stdout: Buffer.concat(out),
|
|
171
210
|
stderr,
|
|
@@ -224,8 +263,17 @@ function parseGitVersion(output) {
|
|
|
224
263
|
minor: Number(m[2])
|
|
225
264
|
};
|
|
226
265
|
}
|
|
227
|
-
|
|
228
|
-
|
|
266
|
+
/** `v` 不低于 `min`。版本比较只此一份——启动下限与 `GIT_NO_LAZY_FETCH` 那道下限共用。 */
|
|
267
|
+
function atLeast(v, min) {
|
|
268
|
+
return v.major > min.major || v.major === min.major && v.minor >= min.minor;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* 完整对象名:SHA-1 40 位 / SHA-256 64 位小写十六进制。**判据只此一份**——status 解析
|
|
272
|
+
* `# branch.oid` 与提交历史校验请求里的 sha 用的是同一条,两份漂开时前端拿 `BranchState.oid` 去比
|
|
273
|
+
* 的东西与后端认的对象名就不是一回事了。
|
|
274
|
+
*/
|
|
275
|
+
function isOid(value) {
|
|
276
|
+
return /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(value);
|
|
229
277
|
}
|
|
230
278
|
/**
|
|
231
279
|
* 前置检查 + 定位,一次做完。两条命令并发跑:串行等于把两次进程启动开销叠加进 300ms
|
|
@@ -235,13 +283,14 @@ async function locateRepo(cwd) {
|
|
|
235
283
|
const [version, located] = await Promise.all([runGit(["--version"], cwd), runGit([
|
|
236
284
|
"rev-parse",
|
|
237
285
|
"--show-toplevel",
|
|
238
|
-
"--git-dir"
|
|
286
|
+
"--git-dir",
|
|
287
|
+
"--git-common-dir"
|
|
239
288
|
], cwd)]).catch((cause) => {
|
|
240
289
|
if (cause instanceof GitError && cause.kind === "missing") throw new PreflightError("git-missing", "git was not found on your PATH. Install git and try again.");
|
|
241
290
|
throw cause;
|
|
242
291
|
});
|
|
243
292
|
const parsed = version.code === 0 ? parseGitVersion(version.stdout) : null;
|
|
244
|
-
if (parsed &&
|
|
293
|
+
if (parsed && !atLeast(parsed, MIN_GIT)) throw new PreflightError("git-too-old", `difftab needs git ${MIN_GIT.major}.${MIN_GIT.minor} or newer (for --porcelain=v2), but found ${parsed.major}.${parsed.minor}.`);
|
|
245
294
|
if (located.code !== 0) {
|
|
246
295
|
const bare = await runGit(["rev-parse", "--is-bare-repository"], cwd);
|
|
247
296
|
if (bare.code === 0 && bare.stdout.trim() === "true") throw new PreflightError("bare-repo", "this is a bare repository — it has no working tree to show changes for.");
|
|
@@ -250,10 +299,13 @@ async function locateRepo(cwd) {
|
|
|
250
299
|
const lines = located.stdout.split("\n");
|
|
251
300
|
const root = lines[0]?.trim();
|
|
252
301
|
const gitDir = lines[1]?.trim();
|
|
253
|
-
|
|
302
|
+
const commonDir = lines[2]?.trim();
|
|
303
|
+
if (!root || !gitDir || !commonDir) throw new PreflightError("not-a-repo", "this directory is not inside a git repository.");
|
|
254
304
|
return {
|
|
255
305
|
root,
|
|
256
|
-
gitDir: isAbsolute(gitDir) ? gitDir : resolve(root, gitDir)
|
|
306
|
+
gitDir: isAbsolute(gitDir) ? gitDir : resolve(root, gitDir),
|
|
307
|
+
commonDir: resolve(cwd, commonDir),
|
|
308
|
+
gitVersion: parsed
|
|
257
309
|
};
|
|
258
310
|
}
|
|
259
311
|
/**
|
|
@@ -276,6 +328,23 @@ function repoNameOf(repo) {
|
|
|
276
328
|
* 变回未出生状态,缓存的正结果会让 diff 从此全部 fatal。
|
|
277
329
|
*/
|
|
278
330
|
async function resolveDiffBase(root) {
|
|
331
|
+
const oid = await headOid(root);
|
|
332
|
+
if (oid !== null) return {
|
|
333
|
+
ref: "HEAD",
|
|
334
|
+
oid
|
|
335
|
+
};
|
|
336
|
+
const empty = await emptyTree(root);
|
|
337
|
+
return {
|
|
338
|
+
ref: empty,
|
|
339
|
+
oid: empty
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* HEAD 此刻指向的提交;**`null` 即 HEAD 未出生**(空仓库、`checkout --orphan` 之后)。「未出生」的
|
|
344
|
+
* 判据只此一份——diff 基准退回空树与提交列表说「没有提交」用的是同一条:`rev-parse --verify` 在
|
|
345
|
+
* 未出生时退 1;指着一个缺失的对象时却退 0 并照样印出 oid(实测),那不是未出生。
|
|
346
|
+
*/
|
|
347
|
+
async function headOid(root) {
|
|
279
348
|
const head = await runGit([
|
|
280
349
|
"rev-parse",
|
|
281
350
|
"--verify",
|
|
@@ -283,16 +352,21 @@ async function resolveDiffBase(root) {
|
|
|
283
352
|
"HEAD"
|
|
284
353
|
], root);
|
|
285
354
|
const oid = head.stdout.trim();
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
355
|
+
return head.code === 0 && oid ? oid : null;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* 某个对象名所在格式下的空树哈希:64 位即 SHA-256,其余按 SHA-1。提交历史手里已经有一个对象名,
|
|
359
|
+
* 用它的长度就能定格式,不必为根提交再问一次 `--show-object-format`。
|
|
360
|
+
*/
|
|
361
|
+
function emptyTreeOf(oid) {
|
|
362
|
+
return oid.length === 64 ? EMPTY_TREE.sha256 : EMPTY_TREE.sha1;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* 本仓库对象格式下的空树哈希——空仓库的 diff 基准。手上还没有任何对象名,只能问 git。
|
|
366
|
+
*/
|
|
367
|
+
async function emptyTree(root) {
|
|
290
368
|
const format = await runGit(["rev-parse", "--show-object-format"], root);
|
|
291
|
-
|
|
292
|
-
return {
|
|
293
|
-
ref: empty,
|
|
294
|
-
oid: empty
|
|
295
|
-
};
|
|
369
|
+
return (format.code === 0 ? format.stdout.trim() : "sha1") === "sha256" ? EMPTY_TREE.sha256 : EMPTY_TREE.sha1;
|
|
296
370
|
}
|
|
297
371
|
|
|
298
372
|
//#endregion
|
|
@@ -346,6 +420,23 @@ function contains(base, target) {
|
|
|
346
420
|
return rel === "" || !rel.startsWith("..") && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
347
421
|
}
|
|
348
422
|
/**
|
|
423
|
+
* 只过**字面量那道**:拒绝绝对路径、含 `\0` 的、以及 `relative()` 走出去的那些,回归一化后的
|
|
424
|
+
* 路径。**只给不落磁盘的路径用**——提交历史里的路径只拼进 pathspec 与 `<rev>:<path>`,那个文件
|
|
425
|
+
* 在工作区里可能早已不在,`realpath` 那道既无从验、也没有东西需要它保护。凡是真要落磁盘的,
|
|
426
|
+
* 一律走下面的 `resolveInRepo`。
|
|
427
|
+
*/
|
|
428
|
+
function literalRepoPath(root, path, allowRoot = false) {
|
|
429
|
+
if (path.includes("\0") || isAbsolute(path)) throw new WorktreeError("invalid-path", "invalid path");
|
|
430
|
+
if (path === "" && !allowRoot) throw new WorktreeError("invalid-path", "invalid path");
|
|
431
|
+
const abs = path === "" ? resolve(root) : resolve(root, path);
|
|
432
|
+
const rel = relative(root, abs);
|
|
433
|
+
if (!contains(root, abs) || rel === "" && !allowRoot) throw new WorktreeError("invalid-path", "invalid path");
|
|
434
|
+
return {
|
|
435
|
+
abs,
|
|
436
|
+
path: rel.split(sep).join("/")
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
349
440
|
* 把请求里的路径落到磁盘上,**两道边界一次过完**。
|
|
350
441
|
*
|
|
351
442
|
* 1. **字面量那道**:拒绝绝对路径、含 `\0` 的、以及 `relative()` 走出去的那些。
|
|
@@ -359,13 +450,7 @@ function contains(base, target) {
|
|
|
359
450
|
* 只取 `abs` 的写法把这份结果扔掉了,才不得不在外面补一条 `endsWith('/')`)。
|
|
360
451
|
*/
|
|
361
452
|
async function resolveInRepo(root, path, options) {
|
|
362
|
-
|
|
363
|
-
if (path === "") {
|
|
364
|
-
if (options.allowRoot !== true) throw new WorktreeError("invalid-path", "invalid path");
|
|
365
|
-
}
|
|
366
|
-
const abs = path === "" ? resolve(root) : resolve(root, path);
|
|
367
|
-
const rel = relative(root, abs);
|
|
368
|
-
if (!contains(root, abs) || rel === "" && options.allowRoot !== true) throw new WorktreeError("invalid-path", "invalid path");
|
|
453
|
+
const { abs, path: normalized } = literalRepoPath(root, path, options.allowRoot === true);
|
|
369
454
|
const realRoot = await realpath(root);
|
|
370
455
|
if (options.follow) {
|
|
371
456
|
let real;
|
|
@@ -386,7 +471,7 @@ async function resolveInRepo(root, path, options) {
|
|
|
386
471
|
}
|
|
387
472
|
return {
|
|
388
473
|
abs,
|
|
389
|
-
path:
|
|
474
|
+
path: normalized
|
|
390
475
|
};
|
|
391
476
|
}
|
|
392
477
|
/**
|
|
@@ -469,6 +554,226 @@ async function inspectFile(root, path) {
|
|
|
469
554
|
};
|
|
470
555
|
}
|
|
471
556
|
|
|
557
|
+
//#endregion
|
|
558
|
+
//#region src/server/git/history.ts
|
|
559
|
+
/** 一页多少条。取 `PAGE_SIZE + 1` 条来判 `hasMore`,不另起一次计数。 */
|
|
560
|
+
const PAGE_SIZE = 50;
|
|
561
|
+
/**
|
|
562
|
+
* 每条提交恰好六段:`-z` 让记录之间以 NUL 分隔,`%x00` 让字段之间也是 NUL。主题行(`%s`)
|
|
563
|
+
* 不含 NUL 与换行,作者名里的空格、正文(`%b`)里的换行都不是分隔符;根提交的 `%P` 是一个空段,
|
|
564
|
+
* 不是缺段。
|
|
565
|
+
*/
|
|
566
|
+
const LOG_FORMAT = "--format=%H%x00%P%x00%an%x00%at%x00%s%x00%b";
|
|
567
|
+
const FIELDS$1 = 6;
|
|
568
|
+
/**
|
|
569
|
+
* `log` 的 argv,**整条是字面量**,冒烟逐段钉着它:
|
|
570
|
+
* - `--no-show-signature`:用户配了 `log.showSignature` 时 `log` 会对每条提交起一次 gpg;
|
|
571
|
+
* - **不带 `-p` / `--stat` / 任何 diff 选项**:一旦算 diff,`diff.external` 与 textconv 驱动就
|
|
572
|
+
* 都在射程内,而白名单只看子命令;
|
|
573
|
+
* - 末尾的 `--`:`rev` 之后的任何东西都不再被当成选项。
|
|
574
|
+
*
|
|
575
|
+
* `rev` 必须已过 `assertOid`——它拼在 revision 位置,`GIT_LITERAL_PATHSPECS` 管不到那里,
|
|
576
|
+
* `--output=<路径>` 在那个位置就是一次写文件。
|
|
577
|
+
*/
|
|
578
|
+
function logArgs(rev, count, skip) {
|
|
579
|
+
return [
|
|
580
|
+
"log",
|
|
581
|
+
"--no-show-signature",
|
|
582
|
+
"--no-color",
|
|
583
|
+
"-z",
|
|
584
|
+
LOG_FORMAT,
|
|
585
|
+
`--max-count=${count}`,
|
|
586
|
+
...skip === void 0 ? [] : [`--skip=${skip}`],
|
|
587
|
+
rev,
|
|
588
|
+
"--"
|
|
589
|
+
];
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* 请求里的提交只认**完整的十六进制对象名**(SHA-1 40 位 / SHA-256 64 位):不认缩写、不认
|
|
593
|
+
* `HEAD~3` 一类 revision 表达式,更不认 `-` 开头的值。前端手里的 sha 全部来自后端自己的输出,
|
|
594
|
+
* 用不着任何 revision 语法。
|
|
595
|
+
*/
|
|
596
|
+
function assertOid(value) {
|
|
597
|
+
if (!isOid(value)) throw new WorktreeError("invalid-path", "not a full commit hash");
|
|
598
|
+
return value;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* `log -z` + `LOG_FORMAT` 的解析:按六段一组切。正文含 NUL 也不会错位——git 输出 `%b` 时在第一个
|
|
602
|
+
* NUL 处截断(实测:`hash-object --literally` 造一条「Reverts\0<sha>\0tail」的说明,回来的正文就是
|
|
603
|
+
* `Reverts`),于是每条记录在输出里恒为六段。
|
|
604
|
+
*/
|
|
605
|
+
function parseLog(output) {
|
|
606
|
+
const segments = output.split("\0");
|
|
607
|
+
const commits = [];
|
|
608
|
+
for (let i = 0; i + FIELDS$1 <= segments.length; i += FIELDS$1) {
|
|
609
|
+
const [sha = "", parents = "", author = "", time = "", subject = "", body = ""] = segments.slice(i, i + FIELDS$1);
|
|
610
|
+
const id = sha.replace(/^\n/, "");
|
|
611
|
+
if (!isOid(id)) continue;
|
|
612
|
+
commits.push({
|
|
613
|
+
sha: id,
|
|
614
|
+
parents: parents === "" ? [] : parents.split(" "),
|
|
615
|
+
author,
|
|
616
|
+
time: Number(time),
|
|
617
|
+
subject,
|
|
618
|
+
body: body.trimEnd()
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
return commits;
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* 提交列表的一页。`head` 缺省即「从 HEAD 起」,并把 HEAD 此刻的 oid 回传当锚点;之后每一页
|
|
625
|
+
* 都带着它再 `--skip`——按 HEAD 往下数时,agent 在两次翻页之间提交一次,第二页就重复第一页的
|
|
626
|
+
* 最后一条。空仓库(HEAD 未出生)是一页空列表,不是错误。
|
|
627
|
+
*/
|
|
628
|
+
async function listCommits(root, query) {
|
|
629
|
+
const rev = query.head === void 0 ? "HEAD" : assertOid(query.head);
|
|
630
|
+
const result = await runGit(logArgs(rev, 51, query.skip), root);
|
|
631
|
+
if (result.code !== 0) {
|
|
632
|
+
if (query.head !== void 0) throw new WorktreeError("not-found", "no such commit");
|
|
633
|
+
if (await headOid(root) === null) return {
|
|
634
|
+
head: null,
|
|
635
|
+
commits: [],
|
|
636
|
+
hasMore: false
|
|
637
|
+
};
|
|
638
|
+
throw new Error("HEAD points to a commit that cannot be read");
|
|
639
|
+
}
|
|
640
|
+
const commits = parseLog(result.stdout);
|
|
641
|
+
return {
|
|
642
|
+
head: query.head ?? commits[0]?.sha ?? null,
|
|
643
|
+
commits: commits.slice(0, 50),
|
|
644
|
+
hasMore: commits.length > 50
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* 把请求里的 sha 落成 `CommitRange`。**对比端只在这里定一次**:三条路各自去找父提交时,将来
|
|
649
|
+
* 改一处(比如合并提交换个父)漏改另一处,文件清单、补丁与图片就是对着三个不同的父在比,而
|
|
650
|
+
* 不报错。
|
|
651
|
+
*
|
|
652
|
+
* 一次 `log -1` 同时做校验与取元数据:对象名合法不等于是本仓库里的一个提交——`log` 对不存在
|
|
653
|
+
* 的对象以 128 退出,对树与 blob **以 0 退出、输出为空**,对附注标签则剥到它指向的提交(实测)。
|
|
654
|
+
* 所以判据是「回来的那条正好就是问的这个 sha」,退出码只是其中一半。
|
|
655
|
+
*/
|
|
656
|
+
async function resolveCommit(repo, sha) {
|
|
657
|
+
assertOid(sha);
|
|
658
|
+
await refuseLazyFetch(repo);
|
|
659
|
+
const result = await runGit(logArgs(sha, 1), repo.root);
|
|
660
|
+
const [commit] = result.code === 0 ? parseLog(result.stdout) : [];
|
|
661
|
+
if (commit?.sha !== sha) throw new WorktreeError("not-found", "no such commit");
|
|
662
|
+
const [first] = commit.parents;
|
|
663
|
+
if (first !== void 0) return {
|
|
664
|
+
commit,
|
|
665
|
+
parent: first
|
|
666
|
+
};
|
|
667
|
+
return {
|
|
668
|
+
commit,
|
|
669
|
+
parent: await isShallowBoundary(repo, sha) ? null : emptyTreeOf(sha)
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
/** 浅克隆边界上的提交没有父可比。补丁与图片两条路据此拒绝,而不是拿空树去比。 */
|
|
673
|
+
function requireParent(range) {
|
|
674
|
+
if (range.parent === null) throw new WorktreeError("unsupported", "shallow clone: this commit has no parent to compare with");
|
|
675
|
+
return range.parent;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* 这条提交是不是浅克隆的边界:共享 git 目录下的 `shallow` 文件逐行列着它们(实测 `--depth 1` 之后
|
|
679
|
+
* 正好是 HEAD 那一条)。只在 `%P` 为空时才读——根提交与边界只有这一处分得开。**不缓存**:
|
|
680
|
+
* `fetch --deepen` 会改这个文件,而这条路只在根提交上走。
|
|
681
|
+
*/
|
|
682
|
+
async function isShallowBoundary(repo, sha) {
|
|
683
|
+
try {
|
|
684
|
+
return (await readFile(join(repo.commonDir, "shallow"), "utf8")).split("\n").some((line) => line.trim() === sha);
|
|
685
|
+
} catch {
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
/** `GIT_NO_LAZY_FETCH` 的版本下限(git 2.44)。 */
|
|
690
|
+
const LAZY_FETCH_SWITCH = {
|
|
691
|
+
major: 2,
|
|
692
|
+
minor: 44
|
|
693
|
+
};
|
|
694
|
+
/**
|
|
695
|
+
* **老 git + partial clone 下提交历史整个拒绝**。`GIT_NO_LAZY_FETCH` 是 git 2.44 才有的变量,更老
|
|
696
|
+
* 的 git 不认它:缺的 blob 照样当场从 promisor remote 取回来写进 `.git/objects`,而提交历史读的
|
|
697
|
+
* 恰恰是那些从没检出过的旧 blob——工作区 diff 只碰 HEAD 里已检出的那一份,不在此列。
|
|
698
|
+
*
|
|
699
|
+
* 版本是启动时 `locateRepo` 解析好的(解析不出按新的算,与启动下限同一取向),partial clone 的记号
|
|
700
|
+
* 读共享 git 目录下的 `config`——与进行中的操作同一个取向:读状态文件,不为它往白名单里添
|
|
701
|
+
* `config`。新 git 上一次文件都不读;老 git 上每次读一个小文件,不另设缓存。
|
|
702
|
+
*/
|
|
703
|
+
async function refuseLazyFetch(repo) {
|
|
704
|
+
if (repo.gitVersion === null || atLeast(repo.gitVersion, LAZY_FETCH_SWITCH)) return;
|
|
705
|
+
let config = "";
|
|
706
|
+
try {
|
|
707
|
+
config = await readFile(join(repo.commonDir, "config"), "utf8");
|
|
708
|
+
} catch {
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (isPartialCloneConfig(config)) throw new WorktreeError("unsupported", "commit history in a partial clone needs git 2.44 or newer");
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* 一份 git `config` 正文里有没有 partial clone 的记号:`extensions.partialClone = <名>`,或
|
|
715
|
+
* `remote.<名>.promisor` 为真——git 的布尔值只写键名、`true` / `yes` / `on` / `1` 都算,不区分大小写。
|
|
716
|
+
*/
|
|
717
|
+
function isPartialCloneConfig(config) {
|
|
718
|
+
return /^\s*(?:partialclone\s*=|promisor\s*(?:$|=\s*"?(?:true|yes|on|1)\b))/im.test(config);
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* `diff --name-status -z -M` 的解析。**重命名记录占三段**:`R<score>` `<旧路径>` `<新路径>`——
|
|
722
|
+
* 与 numstat 同为旧在前、与 porcelain 的 `2 ` 记录相反。平铺切分会把旧路径当成下一条记录的
|
|
723
|
+
* 状态字段。没开 `-C`,`C` 不会出现;认不出的状态字母整条跳过,不猜。
|
|
724
|
+
*/
|
|
725
|
+
function parseNameStatus(output) {
|
|
726
|
+
const segments = output.split("\0");
|
|
727
|
+
const files = [];
|
|
728
|
+
for (let i = 0; i < segments.length;) {
|
|
729
|
+
const status = segments[i] ?? "";
|
|
730
|
+
const letter = status[0];
|
|
731
|
+
if (letter === "R" || letter === "C") {
|
|
732
|
+
const oldPath = segments[i + 1] ?? "";
|
|
733
|
+
const path = segments[i + 2] ?? "";
|
|
734
|
+
if (letter === "R") files.push({
|
|
735
|
+
path,
|
|
736
|
+
oldPath,
|
|
737
|
+
status: "R"
|
|
738
|
+
});
|
|
739
|
+
i += 3;
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
if (letter === "A" || letter === "M" || letter === "D" || letter === "T") {
|
|
743
|
+
files.push({
|
|
744
|
+
path: segments[i + 1] ?? "",
|
|
745
|
+
status: letter
|
|
746
|
+
});
|
|
747
|
+
i += 2;
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
i += status === "" ? 1 : 2;
|
|
751
|
+
}
|
|
752
|
+
return files;
|
|
753
|
+
}
|
|
754
|
+
/** 一次提交的元数据 + 改了哪些文件(相对第一父)。 */
|
|
755
|
+
async function readCommit(repo, sha) {
|
|
756
|
+
const { commit, parent } = await resolveCommit(repo, sha);
|
|
757
|
+
if (parent === null) return {
|
|
758
|
+
...commit,
|
|
759
|
+
files: [],
|
|
760
|
+
shallow: true
|
|
761
|
+
};
|
|
762
|
+
const output = await runGitStrict([
|
|
763
|
+
"diff",
|
|
764
|
+
parent,
|
|
765
|
+
commit.sha,
|
|
766
|
+
"--name-status",
|
|
767
|
+
"-z",
|
|
768
|
+
"-M",
|
|
769
|
+
"--"
|
|
770
|
+
], repo.root);
|
|
771
|
+
return {
|
|
772
|
+
...commit,
|
|
773
|
+
files: parseNameStatus(output)
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
472
777
|
//#endregion
|
|
473
778
|
//#region src/server/git/image.ts
|
|
474
779
|
/**
|
|
@@ -501,9 +806,17 @@ async function baseBlobSize(root, baseRef, normalizedPath) {
|
|
|
501
806
|
* 长大。`new` 侧走 `inspectFile`——与 payload 同一条链,NUL ∧ 扩展名两半都过;`old` 侧带
|
|
502
807
|
* `maxStdoutBytes` 去撞,超限即就地掐断 git。
|
|
503
808
|
*/
|
|
504
|
-
async function readImageBytes(
|
|
809
|
+
async function readImageBytes(repo, path, side, commit) {
|
|
810
|
+
const { root } = repo;
|
|
505
811
|
const mime = imageMimeOf(path);
|
|
506
812
|
if (mime === null) throw new WorktreeError("invalid-path", "not an image");
|
|
813
|
+
if (commit !== void 0) {
|
|
814
|
+
const resolved = await resolveCommit(repo, commit);
|
|
815
|
+
return {
|
|
816
|
+
buffer: await readBlob(root, side === "new" ? resolved.commit.sha : requireParent(resolved), literalRepoPath(root, path).path),
|
|
817
|
+
mime
|
|
818
|
+
};
|
|
819
|
+
}
|
|
507
820
|
if (side === "new") {
|
|
508
821
|
const file = await inspectFile(root, path);
|
|
509
822
|
if (file.kind === "too-large") throw new WorktreeError("too-large", "image too large");
|
|
@@ -514,22 +827,26 @@ async function readImageBytes(root, path, side) {
|
|
|
514
827
|
};
|
|
515
828
|
}
|
|
516
829
|
const [base, { path: normalized }] = await Promise.all([resolveDiffBase(root), resolveInRepo(root, path, { follow: false })]);
|
|
830
|
+
return {
|
|
831
|
+
buffer: await readBlob(root, base.ref, normalized),
|
|
832
|
+
mime
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
/** `cat-file blob <rev>:<path>`,带 `maxStdoutBytes` 去撞 5MB。 */
|
|
836
|
+
async function readBlob(root, rev, normalizedPath) {
|
|
517
837
|
let result;
|
|
518
838
|
try {
|
|
519
839
|
result = await runGitRaw([
|
|
520
840
|
"cat-file",
|
|
521
841
|
"blob",
|
|
522
|
-
blobRef(
|
|
842
|
+
blobRef(rev, normalizedPath)
|
|
523
843
|
], root, { maxStdoutBytes: MAX_BYTES });
|
|
524
844
|
} catch (cause) {
|
|
525
845
|
if (cause instanceof GitError && cause.kind === "overflow") throw new WorktreeError("too-large", "image too large");
|
|
526
846
|
throw cause;
|
|
527
847
|
}
|
|
528
848
|
if (result.code !== 0) throw new WorktreeError("not-found", "no such file in the diff base");
|
|
529
|
-
return
|
|
530
|
-
buffer: result.stdout,
|
|
531
|
-
mime
|
|
532
|
-
};
|
|
849
|
+
return result.stdout;
|
|
533
850
|
}
|
|
534
851
|
|
|
535
852
|
//#endregion
|
|
@@ -577,7 +894,7 @@ function parseNumstat(output) {
|
|
|
577
894
|
return records;
|
|
578
895
|
}
|
|
579
896
|
/**
|
|
580
|
-
* 「基准 →
|
|
897
|
+
* 「基准 → 工作区」(或提交历史里「父 → 提交」)这条路径上的 numstat 记录;不在差异里就是 `null`。这一次调用同时回答
|
|
581
898
|
* 三个问题,所以它取代了原先那次 `diff --name-only`:
|
|
582
899
|
* 1. **它是不是已跟踪的**。已跟踪是 **HEAD ∪ index**,而 `ls-files` 只答得出 index——
|
|
583
900
|
* `git rm` 之后已暂存的删除在 index 里已经没有了,status 却照样报 `1 D.`、基准侧也
|
|
@@ -589,21 +906,10 @@ function parseNumstat(output) {
|
|
|
589
906
|
* 重命名同样要传两个路径 + `-M`:只传新路径时 git 无法配对,numstat 会把它算成一个全
|
|
590
907
|
* 新增文件(行数因此是整份文件),与补丁那一侧的退化是同一回事。
|
|
591
908
|
*/
|
|
592
|
-
async function readNumstat(root,
|
|
593
|
-
|
|
594
|
-
* **一次查询可以回不止一条记录**,所以必须按路径挑、不能取 `[0]`:传了两个路径而 git
|
|
595
|
-
* **配不上对**时(`git mv` 之后把内容全部重写、留在工作区不 `git add`——status 照报
|
|
596
|
-
* `R100`,所以 `oldPath` 是有的),它会拆成「删掉旧的」+「新增新的」两条并按路径排序。
|
|
597
|
-
* 取 `[0]` 就是掷硬币:实测那条 60,000 行的改名里排在前面的是旧文件那条 100 行的删除,
|
|
598
|
-
* 于是行数闸放它过去,一份 6 万行的补丁照旧发给浏览器。二进制同理。
|
|
599
|
-
*
|
|
600
|
-
* 挑中之后**合并**而不是取一条:那份补丁里两个文件都在,前端要渲染的是两者之和。挑的是
|
|
601
|
-
* `path` 字段——git 的重命名检测发生在 pathspec 过滤**之后**,配上对与配不上对的记录
|
|
602
|
-
* 都落在我们给的这两个路径里,因此不必再比 `record.oldPath`。
|
|
603
|
-
*/
|
|
604
|
-
const mine = parseNumstat(await runGitStrict(oldPath ? [
|
|
909
|
+
async function readNumstat(root, range, path, oldPath) {
|
|
910
|
+
const args = oldPath ? [
|
|
605
911
|
"diff",
|
|
606
|
-
|
|
912
|
+
...range,
|
|
607
913
|
"--numstat",
|
|
608
914
|
"-z",
|
|
609
915
|
"-M",
|
|
@@ -612,12 +918,24 @@ async function readNumstat(root, base, path, oldPath) {
|
|
|
612
918
|
oldPath
|
|
613
919
|
] : [
|
|
614
920
|
"diff",
|
|
615
|
-
|
|
921
|
+
...range,
|
|
616
922
|
"--numstat",
|
|
617
923
|
"-z",
|
|
618
924
|
"--",
|
|
619
925
|
path
|
|
620
|
-
]
|
|
926
|
+
];
|
|
927
|
+
/**
|
|
928
|
+
* **一次查询可以回不止一条记录**,所以必须按路径挑、不能取 `[0]`:传了两个路径而 git
|
|
929
|
+
* **配不上对**时(`git mv` 之后把内容全部重写、留在工作区不 `git add`——status 照报
|
|
930
|
+
* `R100`,所以 `oldPath` 是有的),它会拆成「删掉旧的」+「新增新的」两条并按路径排序。
|
|
931
|
+
* 取 `[0]` 就是掷硬币:实测那条 60,000 行的改名里排在前面的是旧文件那条 100 行的删除,
|
|
932
|
+
* 于是行数闸放它过去,一份 6 万行的补丁照旧发给浏览器。二进制同理。
|
|
933
|
+
*
|
|
934
|
+
* 挑中之后**合并**而不是取一条:那份补丁里两个文件都在,前端要渲染的是两者之和。挑的是
|
|
935
|
+
* `path` 字段——git 的重命名检测发生在 pathspec 过滤**之后**,配上对与配不上对的记录
|
|
936
|
+
* 都落在我们给的这两个路径里,因此不必再比 `record.oldPath`。
|
|
937
|
+
*/
|
|
938
|
+
const mine = parseNumstat(await runGitStrict(args, root)).filter((record) => record.path === path || oldPath !== void 0 && record.path === oldPath);
|
|
621
939
|
if (mine.length === 0) return null;
|
|
622
940
|
return {
|
|
623
941
|
binary: mine.some((record) => record.binary),
|
|
@@ -653,7 +971,23 @@ async function worktreeSize(abs) {
|
|
|
653
971
|
async function imageDiff(root, base, path, abs, oldPath) {
|
|
654
972
|
const basePath = oldPath ?? path;
|
|
655
973
|
const [oldSize, info] = await Promise.all([baseBlobSize(root, base.ref, basePath), worktreeStat(abs)]);
|
|
656
|
-
|
|
974
|
+
return imagePayload(oldSize === null ? null : {
|
|
975
|
+
path: basePath,
|
|
976
|
+
size: oldSize,
|
|
977
|
+
version: base.oid
|
|
978
|
+
}, info === null ? null : {
|
|
979
|
+
path,
|
|
980
|
+
size: info.size,
|
|
981
|
+
version: worktreeVersion(info)
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* 两侧元数据 → payload,工作区与提交两条路共用。**5MB 按侧卡**:任一侧超过就整个回 `too-large`;
|
|
986
|
+
* `size` 取两侧里大的那个(取不到给 0)。两份各写时阈值或取哪一侧的体积漂开,同一张图在两个视图
|
|
987
|
+
* 里一个说太大、一个照常画。
|
|
988
|
+
*/
|
|
989
|
+
function imagePayload(old, fresh) {
|
|
990
|
+
const largest = Math.max(old?.size ?? 0, fresh?.size ?? 0);
|
|
657
991
|
if (largest > 5242880) return {
|
|
658
992
|
kind: "too-large",
|
|
659
993
|
size: largest,
|
|
@@ -661,16 +995,8 @@ async function imageDiff(root, base, path, abs, oldPath) {
|
|
|
661
995
|
};
|
|
662
996
|
return {
|
|
663
997
|
kind: "image",
|
|
664
|
-
old
|
|
665
|
-
|
|
666
|
-
size: oldSize,
|
|
667
|
-
version: base.oid
|
|
668
|
-
},
|
|
669
|
-
new: info === null ? null : {
|
|
670
|
-
path,
|
|
671
|
-
size: info.size,
|
|
672
|
-
version: worktreeVersion(info)
|
|
673
|
-
}
|
|
998
|
+
old,
|
|
999
|
+
new: fresh
|
|
674
1000
|
};
|
|
675
1001
|
}
|
|
676
1002
|
/**
|
|
@@ -697,22 +1023,33 @@ async function imageDiff(root, base, path, abs, oldPath) {
|
|
|
697
1023
|
* 重命名退化成一个全新增文件。两个路径都来自 status 的 `2 ` 记录,无需额外查询。
|
|
698
1024
|
*/
|
|
699
1025
|
async function trackedDiff(root, base, path, abs, oldPath, stat) {
|
|
700
|
-
|
|
1026
|
+
return gatedPatch(root, {
|
|
1027
|
+
range: [base.ref],
|
|
1028
|
+
path,
|
|
1029
|
+
oldPath,
|
|
1030
|
+
stat,
|
|
1031
|
+
image: () => imageDiff(root, base, path, abs, oldPath),
|
|
1032
|
+
size: async () => await worktreeSize(abs) ?? 0
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
async function gatedPatch(root, source) {
|
|
1036
|
+
const { range, path, oldPath, stat } = source;
|
|
1037
|
+
if (stat?.binary) return imageMimeOf(path) === null ? { kind: "binary" } : source.image();
|
|
701
1038
|
if (stat && stat.lines > 5e4) return {
|
|
702
1039
|
kind: "too-large",
|
|
703
|
-
size: await
|
|
1040
|
+
size: await source.size(),
|
|
704
1041
|
reason: "lines"
|
|
705
1042
|
};
|
|
706
1043
|
const args = oldPath ? [
|
|
707
1044
|
"diff",
|
|
708
|
-
|
|
1045
|
+
...range,
|
|
709
1046
|
"-M",
|
|
710
1047
|
"--",
|
|
711
1048
|
path,
|
|
712
1049
|
oldPath
|
|
713
1050
|
] : [
|
|
714
1051
|
"diff",
|
|
715
|
-
|
|
1052
|
+
...range,
|
|
716
1053
|
"--",
|
|
717
1054
|
path
|
|
718
1055
|
];
|
|
@@ -722,7 +1059,7 @@ async function trackedDiff(root, base, path, abs, oldPath, stat) {
|
|
|
722
1059
|
} catch (cause) {
|
|
723
1060
|
if (cause instanceof GitError && cause.kind === "overflow") return {
|
|
724
1061
|
kind: "too-large",
|
|
725
|
-
size: await
|
|
1062
|
+
size: await source.size(),
|
|
726
1063
|
reason: "size"
|
|
727
1064
|
};
|
|
728
1065
|
throw cause;
|
|
@@ -794,10 +1131,53 @@ async function readDiff(root, query) {
|
|
|
794
1131
|
const { abs, path } = await resolveInRepo(root, query.path, { follow: false });
|
|
795
1132
|
const oldPath = query.oldPath ? (await resolveInRepo(root, query.oldPath, { follow: false })).path : void 0;
|
|
796
1133
|
const [base, listed] = await Promise.all([resolveDiffBase(root), inIndex(root, path)]);
|
|
797
|
-
const stat = await readNumstat(root, base.ref, path, oldPath);
|
|
1134
|
+
const stat = await readNumstat(root, [base.ref], path, oldPath);
|
|
798
1135
|
if (listed || stat !== null) return trackedDiff(root, base, path, abs, oldPath, stat);
|
|
799
1136
|
return untrackedDiff(root, path);
|
|
800
1137
|
}
|
|
1138
|
+
/**
|
|
1139
|
+
* 一次提交里单个文件的补丁(相对第一父)。三道闸与工作区那条同一份(`gatedPatch`);两侧都在
|
|
1140
|
+
* 对象库里,没有未跟踪那条路、也不读磁盘——路径因此只过字面量那道边界(文件在工作区里可能
|
|
1141
|
+
* 早已不在),归一化后的那份照样拼进 `<rev>:<path>`。numstat 不回记录即「这次提交没动它」,
|
|
1142
|
+
* 是坏请求。
|
|
1143
|
+
*/
|
|
1144
|
+
async function readCommitDiff(repo, sha, query) {
|
|
1145
|
+
const { root } = repo;
|
|
1146
|
+
const path = literalRepoPath(root, query.path).path;
|
|
1147
|
+
const oldPath = query.oldPath ? literalRepoPath(root, query.oldPath).path : void 0;
|
|
1148
|
+
const resolved = await resolveCommit(repo, sha);
|
|
1149
|
+
const parent = requireParent(resolved);
|
|
1150
|
+
const { commit } = resolved;
|
|
1151
|
+
const range = [parent, commit.sha];
|
|
1152
|
+
const stat = await readNumstat(root, range, path, oldPath);
|
|
1153
|
+
if (stat === null) throw new WorktreeError("not-found", "file not changed in this commit");
|
|
1154
|
+
return gatedPatch(root, {
|
|
1155
|
+
range,
|
|
1156
|
+
path,
|
|
1157
|
+
oldPath,
|
|
1158
|
+
stat,
|
|
1159
|
+
image: () => commitImageDiff(root, parent, commit.sha, path, oldPath),
|
|
1160
|
+
size: async () => await baseBlobSize(root, commit.sha, path) ?? await baseBlobSize(root, parent, oldPath ?? path) ?? 0
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* 提交里图片的两侧,**都读对象库**:旧侧 `<parent>:<旧路径或路径>`、新侧 `<sha>:<路径>`,与
|
|
1165
|
+
* 工作区 diff 的旧侧同一条 `cat-file -s`。`version` 是两端的对象名——提交不可变,这两个身份
|
|
1166
|
+
* 永远不会换内容。
|
|
1167
|
+
*/
|
|
1168
|
+
async function commitImageDiff(root, parent, sha, path, oldPath) {
|
|
1169
|
+
const basePath = oldPath ?? path;
|
|
1170
|
+
const [oldSize, newSize] = await Promise.all([baseBlobSize(root, parent, basePath), baseBlobSize(root, sha, path)]);
|
|
1171
|
+
return imagePayload(oldSize === null ? null : {
|
|
1172
|
+
path: basePath,
|
|
1173
|
+
size: oldSize,
|
|
1174
|
+
version: parent
|
|
1175
|
+
}, newSize === null ? null : {
|
|
1176
|
+
path,
|
|
1177
|
+
size: newSize,
|
|
1178
|
+
version: sha
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
801
1181
|
|
|
802
1182
|
//#endregion
|
|
803
1183
|
//#region src/server/git/file.ts
|
|
@@ -823,6 +1203,82 @@ async function readFileContent(root, path) {
|
|
|
823
1203
|
}
|
|
824
1204
|
}
|
|
825
1205
|
|
|
1206
|
+
//#endregion
|
|
1207
|
+
//#region src/server/git/refs.ts
|
|
1208
|
+
/**
|
|
1209
|
+
* 十一段,**每段都以 `%00` 收尾(含最后一段)**:`for-each-ref` 没有 `-z`,记录之间是换行,于是
|
|
1210
|
+
* 整份输出按 NUL 切开后恰好十一段一组、下一条的 refname 前面多一个换行。后五段是解引用形态——附注
|
|
1211
|
+
* 标签的本体是标签对象,作者与时间为空、主题是标签说明,`%(*…)` 才是它指向的对象。只用 git 2.11
|
|
1212
|
+
* 已有的 atom:`refname:lstrip` 更晚,前缀在下面剥。
|
|
1213
|
+
*/
|
|
1214
|
+
const REF_FORMAT = "--format=%(refname)%00%(symref)%00%(objectname)%00%(authorname)%00%(committerdate:unix)%00%(subject)%00%(*objectname)%00%(*objecttype)%00%(*authorname)%00%(*committerdate:unix)%00%(*subject)%00";
|
|
1215
|
+
const FIELDS = 11;
|
|
1216
|
+
/**
|
|
1217
|
+
* `for-each-ref` 的 argv,**整条是字面量**,冒烟逐段钉着它:atom 里有 `%(signature)` 一族,用上就
|
|
1218
|
+
* 是每条 ref 起一次 gpg,而白名单只看子命令。不带 `--sort`——三组本来就要在 JS 里分,排序一起做,
|
|
1219
|
+
* 不多一段要钉的参数面。
|
|
1220
|
+
*/
|
|
1221
|
+
const REF_ARGS = [
|
|
1222
|
+
"for-each-ref",
|
|
1223
|
+
REF_FORMAT,
|
|
1224
|
+
"refs/heads",
|
|
1225
|
+
"refs/remotes",
|
|
1226
|
+
"refs/tags"
|
|
1227
|
+
];
|
|
1228
|
+
/** 前缀 → 组。次序就是三组在列表里的次序。 */
|
|
1229
|
+
const KINDS = [
|
|
1230
|
+
["refs/heads/", "local"],
|
|
1231
|
+
["refs/remotes/", "remote"],
|
|
1232
|
+
["refs/tags/", "tag"]
|
|
1233
|
+
];
|
|
1234
|
+
const ORDER = {
|
|
1235
|
+
local: 0,
|
|
1236
|
+
remote: 1,
|
|
1237
|
+
tag: 2
|
|
1238
|
+
};
|
|
1239
|
+
/** `REF_FORMAT` 的解析:十一段一组切,滤掉 symref,附注标签取解引用那几段。 */
|
|
1240
|
+
function parseRefs(output) {
|
|
1241
|
+
const segments = output.split("\0");
|
|
1242
|
+
const refs = [];
|
|
1243
|
+
for (let i = 0; i + FIELDS <= segments.length; i += FIELDS) {
|
|
1244
|
+
const [rawName = "", symref = "", objectSha = "", objectAuthor = "", objectTime = "", objectSubject = "", peeledSha = "", peeledType = "", peeledAuthor = "", peeledTime = "", peeledSubject = ""] = segments.slice(i, i + FIELDS);
|
|
1245
|
+
if (symref !== "") continue;
|
|
1246
|
+
const refname = rawName.replace(/^\n/, "");
|
|
1247
|
+
const match = KINDS.find(([prefix]) => refname.startsWith(prefix));
|
|
1248
|
+
if (match === void 0) continue;
|
|
1249
|
+
const [prefix, kind] = match;
|
|
1250
|
+
const [sha, author, time, subject] = peeledSha === "" ? [
|
|
1251
|
+
objectSha,
|
|
1252
|
+
objectAuthor,
|
|
1253
|
+
objectTime,
|
|
1254
|
+
objectSubject
|
|
1255
|
+
] : peeledType === "commit" ? [
|
|
1256
|
+
peeledSha,
|
|
1257
|
+
peeledAuthor,
|
|
1258
|
+
peeledTime,
|
|
1259
|
+
peeledSubject
|
|
1260
|
+
] : [
|
|
1261
|
+
peeledSha,
|
|
1262
|
+
"",
|
|
1263
|
+
"",
|
|
1264
|
+
""
|
|
1265
|
+
];
|
|
1266
|
+
refs.push({
|
|
1267
|
+
kind,
|
|
1268
|
+
name: refname.slice(prefix.length),
|
|
1269
|
+
sha,
|
|
1270
|
+
author,
|
|
1271
|
+
time: Number(time) || 0,
|
|
1272
|
+
subject
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
return refs.sort((a, b) => ORDER[a.kind] - ORDER[b.kind] || b.time - a.time);
|
|
1276
|
+
}
|
|
1277
|
+
/** 本地、远程、标签三组。空仓库与没有任何 ref 时 git 输出为空、exit 0,回一个空列表。 */
|
|
1278
|
+
async function listRefs(root) {
|
|
1279
|
+
return { refs: parseRefs(await runGitStrict(REF_ARGS, root)) };
|
|
1280
|
+
}
|
|
1281
|
+
|
|
826
1282
|
//#endregion
|
|
827
1283
|
//#region src/server/git/operation.ts
|
|
828
1284
|
/**
|
|
@@ -946,6 +1402,7 @@ function parseStatus(raw) {
|
|
|
946
1402
|
let head = "";
|
|
947
1403
|
let detached = false;
|
|
948
1404
|
let upstream = null;
|
|
1405
|
+
let oid;
|
|
949
1406
|
for (let i = 0; i < segments.length; i += 1) {
|
|
950
1407
|
const rec = segments[i];
|
|
951
1408
|
if (!rec) continue;
|
|
@@ -954,7 +1411,9 @@ function parseStatus(raw) {
|
|
|
954
1411
|
const sp = rec.indexOf(" ", 2);
|
|
955
1412
|
const key = sp === -1 ? rec.slice(2) : rec.slice(2, sp);
|
|
956
1413
|
const value = sp === -1 ? "" : rec.slice(sp + 1);
|
|
957
|
-
if (key === "branch.
|
|
1414
|
+
if (key === "branch.oid") {
|
|
1415
|
+
if (isOid(value)) oid = value;
|
|
1416
|
+
} else if (key === "branch.head") {
|
|
958
1417
|
head = value;
|
|
959
1418
|
detached = value === "(detached)";
|
|
960
1419
|
} else if (key === "branch.ab") {
|
|
@@ -1020,7 +1479,8 @@ function parseStatus(raw) {
|
|
|
1020
1479
|
branch: {
|
|
1021
1480
|
head,
|
|
1022
1481
|
detached,
|
|
1023
|
-
upstream
|
|
1482
|
+
upstream,
|
|
1483
|
+
...oid === void 0 ? {} : { oid }
|
|
1024
1484
|
},
|
|
1025
1485
|
files
|
|
1026
1486
|
};
|
|
@@ -2094,7 +2554,7 @@ async function startServer(repo, options = {}) {
|
|
|
2094
2554
|
}
|
|
2095
2555
|
/**
|
|
2096
2556
|
* 图片字节,**唯一一个正文不是 JSON 的 API 端点**。`path` / `side` 都必填:`old` 是
|
|
2097
|
-
* diff 基准里的 blob、`new`
|
|
2557
|
+
* diff 基准里的 blob、`new` 是工作区那份;带 `commit` 时两侧都读对象库(第一父 / 提交本身)。只服务图片扩展名表里的路径、`Content-Type`
|
|
2098
2558
|
* 按表给精确 MIME(`nosniff` 之下类型错一个字浏览器就不画),非图片一律 400——判据与
|
|
2099
2559
|
* 5MB 那道闸都在 git 那侧,这里只把字节发出去。前端加的 `v=` 戳这里不看。
|
|
2100
2560
|
*/
|
|
@@ -2109,10 +2569,57 @@ async function startServer(repo, options = {}) {
|
|
|
2109
2569
|
sendError(res, 400, "bad-request", "side must be old or new");
|
|
2110
2570
|
return;
|
|
2111
2571
|
}
|
|
2112
|
-
const
|
|
2572
|
+
const commit = url.searchParams.get("commit");
|
|
2573
|
+
const image = await readImageBytes(repo, path, side, commit ?? void 0);
|
|
2113
2574
|
send(res, 200, image.buffer, image.mime);
|
|
2114
2575
|
return;
|
|
2115
2576
|
}
|
|
2577
|
+
/**
|
|
2578
|
+
* 提交列表的一页。`head` 缺省即「从 HEAD 起」,响应里回传的 `head` 就是之后各页要带的锚点;
|
|
2579
|
+
* `skip` 缺省为 0。两者的合法性(完整对象名、非负整数)在这里与 git 那侧各判一半:
|
|
2580
|
+
* 数字归这里,对象名归 `assertOid`——它也是 `/api/commit` 那条路的同一把钥匙。
|
|
2581
|
+
*/
|
|
2582
|
+
case "/api/commits": {
|
|
2583
|
+
const head = url.searchParams.get("head");
|
|
2584
|
+
const rawSkip = url.searchParams.get("skip") ?? "0";
|
|
2585
|
+
if (!/^\d{1,9}$/.test(rawSkip)) {
|
|
2586
|
+
sendError(res, 400, "bad-request", "skip must be a non-negative integer");
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2589
|
+
sendJson(res, 200, await listCommits(repo.root, {
|
|
2590
|
+
head: head ?? void 0,
|
|
2591
|
+
skip: Number(rawSkip)
|
|
2592
|
+
}));
|
|
2593
|
+
return;
|
|
2594
|
+
}
|
|
2595
|
+
case "/api/commit": {
|
|
2596
|
+
const sha = url.searchParams.get("sha");
|
|
2597
|
+
if (!sha) {
|
|
2598
|
+
sendError(res, 400, "bad-request", "sha is required");
|
|
2599
|
+
return;
|
|
2600
|
+
}
|
|
2601
|
+
sendJson(res, 200, await readCommit(repo, sha));
|
|
2602
|
+
return;
|
|
2603
|
+
}
|
|
2604
|
+
/** 一次提交里单个文件的补丁(相对第一父)。`oldPath` 只有重命名传,与 `/api/diff` 同。 */
|
|
2605
|
+
case "/api/commit-diff": {
|
|
2606
|
+
const sha = url.searchParams.get("sha");
|
|
2607
|
+
const path = url.searchParams.get("path");
|
|
2608
|
+
if (!sha || !path) {
|
|
2609
|
+
sendError(res, 400, "bad-request", "sha and path are required");
|
|
2610
|
+
return;
|
|
2611
|
+
}
|
|
2612
|
+
const oldPath = url.searchParams.get("oldPath");
|
|
2613
|
+
sendJson(res, 200, await readCommitDiff(repo, sha, {
|
|
2614
|
+
path,
|
|
2615
|
+
...oldPath ? { oldPath } : {}
|
|
2616
|
+
}));
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
2619
|
+
/** 分支列表:本地、远程、标签。无参数,前端每次打开列表时取一次。 */
|
|
2620
|
+
case "/api/refs":
|
|
2621
|
+
sendJson(res, 200, await listRefs(repo.root));
|
|
2622
|
+
return;
|
|
2116
2623
|
/** SSE。**三道校验在上面已经统一走过**,这里没有例外分支。 */
|
|
2117
2624
|
case "/api/events": {
|
|
2118
2625
|
const headers = {
|
|
@@ -2162,6 +2669,10 @@ async function startServer(repo, options = {}) {
|
|
|
2162
2669
|
sendError(res, 400, cause.code, cause.message);
|
|
2163
2670
|
return;
|
|
2164
2671
|
}
|
|
2672
|
+
if (cause instanceof GitError && cause.kind === "missing-object") {
|
|
2673
|
+
sendError(res, 400, "unsupported", "not available locally (partial clone); difftab does not fetch");
|
|
2674
|
+
return;
|
|
2675
|
+
}
|
|
2165
2676
|
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
2166
2677
|
sendError(res, 500, "internal", detail);
|
|
2167
2678
|
});
|
|
@@ -2559,12 +3070,15 @@ function fail(message) {
|
|
|
2559
3070
|
* EPIPE 失败,而**在 Windows 上管道写是异步的**(POSIX 上同步),失败因此以一个 `'error'`
|
|
2560
3071
|
* 事件到达——零监听器的流收到 `'error'` 就是整个进程带着裸栈以 1 退出。
|
|
2561
3072
|
*
|
|
2562
|
-
*
|
|
3073
|
+
* **macOS 上同一件事还有第二个码**:stdio 管道在那里是 unix socket,对端刚关闭时内核偶尔回
|
|
3074
|
+
* EPROTOTYPE,libuv 把它翻译成 ECONNRESET——与 EPIPE 是同一个事实的两种拼法。
|
|
3075
|
+
*
|
|
3076
|
+
* 服务本身没坏(浏览器照常用得上),所以这里只是把这两个码咽掉。**别的错误照旧抛**:
|
|
2563
3077
|
* 那是真出事了,不该借这条路一起被吞掉。
|
|
2564
3078
|
*/
|
|
2565
3079
|
function ignoreBrokenPipe(stream) {
|
|
2566
3080
|
stream.on("error", (cause) => {
|
|
2567
|
-
if (cause.code !== "EPIPE") throw cause;
|
|
3081
|
+
if (cause.code !== "EPIPE" && cause.code !== "ECONNRESET") throw cause;
|
|
2568
3082
|
});
|
|
2569
3083
|
}
|
|
2570
3084
|
async function main(argv) {
|