difftab 0.5.2 → 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.
@@ -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 = [...GLOBAL_CONFIG, ...args];
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
- function tooOld(v) {
228
- return v.major < MIN_GIT.major || v.major === MIN_GIT.major && v.minor < MIN_GIT.minor;
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 && tooOld(parsed)) 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}.`);
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
- if (!root || !gitDir) throw new PreflightError("not-a-repo", "this directory is not inside a git repository.");
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
- if (head.code === 0 && oid) return {
287
- ref: "HEAD",
288
- oid
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
- const empty = (format.code === 0 ? format.stdout.trim() : "sha1") === "sha256" ? EMPTY_TREE.sha256 : EMPTY_TREE.sha1;
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
- if (path.includes("\0") || isAbsolute(path)) throw new WorktreeError("invalid-path", "invalid path");
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: rel.split(sep).join("/")
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(root, path, side) {
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(base.ref, normalized)
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
- * 「基准 → 工作区」这条路径上的 numstat 记录;不在差异里就是 `null`。这一次调用同时回答
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, base, path, oldPath) {
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
- base,
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
- base,
921
+ ...range,
616
922
  "--numstat",
617
923
  "-z",
618
924
  "--",
619
925
  path
620
- ], root)).filter((record) => record.path === path || oldPath !== void 0 && record.path === oldPath);
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
- const largest = Math.max(oldSize ?? 0, info?.size ?? 0);
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: oldSize === null ? null : {
665
- path: basePath,
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
- if (stat?.binary) return imageMimeOf(path) === null ? { kind: "binary" } : imageDiff(root, base, path, abs, oldPath);
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 worktreeSize(abs) ?? 0,
1040
+ size: await source.size(),
704
1041
  reason: "lines"
705
1042
  };
706
1043
  const args = oldPath ? [
707
1044
  "diff",
708
- base.ref,
1045
+ ...range,
709
1046
  "-M",
710
1047
  "--",
711
1048
  path,
712
1049
  oldPath
713
1050
  ] : [
714
1051
  "diff",
715
- base.ref,
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 worktreeSize(abs) ?? 0,
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.head") {
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` 是工作区那份。只服务图片扩展名表里的路径、`Content-Type`
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 image = await readImageBytes(repo.root, path, side);
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
  });