@konnga/dsh-hud 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,458 @@
1
+ import { resolveCommit, runGit } from './git.js';
2
+ import { parseDiff, parseNameStatus, parseStatus, truncateDiff } from './parse.js';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ /**
6
+ * git 的查询读取器:状态、差异、提交、标签、审阅签名。
7
+ */
8
+ /** 插件名(Cordis 插件面的一部分)。 */
9
+ /** 无缓存地采集一次状态(测试与缓存未命中时使用)。 */
10
+ export async function readStatus(cwd) {
11
+ const root = await runGit(cwd, ['rev-parse', '--show-toplevel']);
12
+ if (!root.ok) {
13
+ if (root.killed)
14
+ throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' });
15
+ if (root.code === 'ENOENT' && !fs.existsSync(cwd))
16
+ throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' });
17
+ if (root.code === 'ENOENT')
18
+ throw Object.assign(new Error('git executable not found'), { code: 'git-missing' });
19
+ // 其余非零退出(典型是 128)就是"不在仓库里"——这是正常结果,不是错误。
20
+ return {
21
+ repo: null,
22
+ branch: null,
23
+ detached: false,
24
+ oid: null,
25
+ upstream: null,
26
+ ahead: 0,
27
+ behind: 0,
28
+ dirty: false,
29
+ counts: emptyCounts(),
30
+ changes: [],
31
+ truncated: false,
32
+ };
33
+ }
34
+ const repoRoot = root.stdout.split('\n')[0].trim();
35
+ if (!repoRoot) {
36
+ return {
37
+ repo: null,
38
+ branch: null,
39
+ detached: false,
40
+ oid: null,
41
+ upstream: null,
42
+ ahead: 0,
43
+ behind: 0,
44
+ dirty: false,
45
+ counts: emptyCounts(),
46
+ changes: [],
47
+ truncated: false,
48
+ };
49
+ }
50
+ // `--untracked-files=all`:未跟踪**按文件**计,而不是把整个未跟踪目录折成一条。
51
+ // 不这样写的话,`?n` 会比编辑器(以及 `git status -uall`)报的数小得多 —— 目录里 200 个新文件
52
+ // 只算 1 条。代价是 git 要走一遍未跟踪树,所以清单仍受 `MAX_CHANGES` 上限保护(超出置 truncated)。
53
+ const status = await runGit(repoRoot, [
54
+ 'status',
55
+ '--porcelain=v2',
56
+ '--branch',
57
+ '-z',
58
+ '--untracked-files=all',
59
+ ]);
60
+ if (!status.ok) {
61
+ if (status.killed)
62
+ throw Object.assign(new Error('git status timed out'), { code: 'timeout' });
63
+ throw Object.assign(new Error(status.stderr.trim() || status.message || 'git status failed'), {
64
+ code: 'git-failed',
65
+ });
66
+ }
67
+ const parsed = parseStatus(status.stdout);
68
+ parsed.repo = repoRoot;
69
+ return parsed;
70
+ }
71
+ /**
72
+ * "这一版"的签名:给每个改动文件取 `size + mtimeMs`,用来判断"我审过的还是不是现在这一版"。
73
+ *
74
+ * 为什么不用 `git diff --numstat`:行数变化抓不到"同数改写"(把 `foo` 改成 `bar`),而
75
+ * `stat` 的 mtime 对任何一次写都会变 —— 也更便宜(一次 syscall,不用再跑一个 git 进程)。
76
+ * 反过来它也会过报:`touch` 一下(内容没变)就会让"已审"失效 —— 宁可让人多看一眼。
77
+ */
78
+ export async function readReviewSignatures(cwd, files) {
79
+ const signatures = {};
80
+ for (const file of files) {
81
+ if (typeof file !== 'string' || file.length === 0)
82
+ continue;
83
+ const absolute = path.join(cwd, file);
84
+ try {
85
+ const info = await fs.promises.stat(absolute);
86
+ signatures[file] = `${info.size}.${Math.round(info.mtimeMs)}`;
87
+ }
88
+ catch {
89
+ // 文件不在了(被删掉 / 被重命名):给一个"不存在"的签名,别让它跟"没审过"混在一起。
90
+ signatures[file] = 'missing';
91
+ }
92
+ }
93
+ return signatures;
94
+ }
95
+ export function emptyCounts() {
96
+ return {
97
+ modified: 0,
98
+ added: 0,
99
+ deleted: 0,
100
+ renamed: 0,
101
+ copied: 0,
102
+ typeChanged: 0,
103
+ untracked: 0,
104
+ conflicted: 0,
105
+ };
106
+ }
107
+ /** 最近提交:默认条数与上限。 */
108
+ export const DEFAULT_COMMITS = 10;
109
+ export const MAX_COMMITS = 50;
110
+ /**
111
+ * 读一个改动路径的**两侧** diff(工作区 / 已暂存),全部只读。
112
+ *
113
+ * 未跟踪文件用 `git diff --no-index -- /dev/null <path>`(该命令以退出码 1 表示
114
+ * "有差异",属于正常结果);重命名把原路径一并作为 pathspec 传入。
115
+ *
116
+ * @param cwd - 会话工作区(绝对路径)。
117
+ * @param file - 仓库内相对路径。
118
+ * @param origFile - 重命名/复制的原路径,可选。
119
+ * @returns `{ repo, path, origPath, untracked, worktree, index }`,两侧都是 parseDiff 的结果。
120
+ */
121
+ export async function readDiff(cwd, file, origFile = null) {
122
+ const root = await runGit(cwd, ['rev-parse', '--show-toplevel']);
123
+ if (!root.ok) {
124
+ if (root.killed)
125
+ throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' });
126
+ if (root.code === 'ENOENT' && !fs.existsSync(cwd))
127
+ throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' });
128
+ if (root.code === 'ENOENT')
129
+ throw Object.assign(new Error('git executable not found'), { code: 'git-missing' });
130
+ return {
131
+ repo: null,
132
+ path: file,
133
+ origPath: origFile,
134
+ untracked: false,
135
+ worktree: parseDiff(''),
136
+ index: parseDiff(''),
137
+ };
138
+ }
139
+ const repoRoot = root.stdout.split('\n')[0].trim();
140
+ const pathspec = origFile === null || origFile === undefined || origFile === '' ? [file] : [file, origFile];
141
+ const tracked = await runGit(repoRoot, ['ls-files', '--error-unmatch', '--', file]);
142
+ const untracked = !tracked.ok;
143
+ let worktreeText = '';
144
+ let worktreeTruncated = false;
145
+ if (untracked) {
146
+ // 以退出码 1 表示"有差异",stdout 就是 unified diff。
147
+ const result = await runGit(repoRoot, [
148
+ 'diff',
149
+ '--no-index',
150
+ '--no-color',
151
+ '--no-ext-diff',
152
+ '--',
153
+ '/dev/null',
154
+ file,
155
+ ]);
156
+ if (!result.ok && (result.code === 'ENOENT' || result.killed)) {
157
+ throw Object.assign(new Error(result.killed ? 'git diff timed out' : 'git executable not found'), { code: result.killed ? 'timeout' : 'git-missing' });
158
+ }
159
+ worktreeText = result.stdout;
160
+ }
161
+ else {
162
+ const result = await runGit(repoRoot, [
163
+ 'diff',
164
+ '--no-color',
165
+ '--no-ext-diff',
166
+ '--',
167
+ ...pathspec,
168
+ ]);
169
+ if (!result.ok)
170
+ throw Object.assign(new Error(result.stderr.trim() || 'git diff failed'), {
171
+ code: 'git-failed',
172
+ });
173
+ worktreeText = result.stdout;
174
+ }
175
+ const indexResult = untracked
176
+ ? { ok: true, stdout: '' }
177
+ : await runGit(repoRoot, ['diff', '--cached', '--no-color', '--no-ext-diff', '--', ...pathspec]);
178
+ if (!indexResult.ok)
179
+ throw Object.assign(new Error(indexResult.stderr?.trim() || 'git diff --cached failed'), {
180
+ code: 'git-failed',
181
+ });
182
+ const worktreeCut = truncateDiff(worktreeText);
183
+ const indexCut = truncateDiff(indexResult.stdout || '');
184
+ const worktree = parseDiff(worktreeCut.text);
185
+ const index = parseDiff(indexCut.text);
186
+ return {
187
+ repo: repoRoot,
188
+ path: file,
189
+ origPath: origFile ?? null,
190
+ untracked,
191
+ worktree: { ...worktree, truncated: worktree.truncated || worktreeCut.truncated },
192
+ index: { ...index, truncated: index.truncated || indexCut.truncated },
193
+ };
194
+ }
195
+ /**
196
+ * 读最近的提交(只读 `git log`)。
197
+ *
198
+ * 用 NUL 分隔的 `--pretty=format:` 而不是默认格式:提交标题里可以有空格、引号、
199
+ * 甚至制表符,用分隔符切比按列对齐稳。空仓库(还没有任何提交)时 `git log` 以非零
200
+ * 退出,这里返回空列表而不是错误——"还没有提交"是正常状态。
201
+ *
202
+ * @param cwd - 会话工作区(绝对路径)。
203
+ * @param count - 条数,钳制在 1..50,默认 10。
204
+ * @returns `{ repo, commits: [{ sha, author, date, committedAt, refs, subject }] }`。
205
+ */
206
+ export async function readLog(cwd, count = DEFAULT_COMMITS) {
207
+ const root = await runGit(cwd, ['rev-parse', '--show-toplevel']);
208
+ if (!root.ok) {
209
+ if (root.killed)
210
+ throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' });
211
+ if (root.code === 'ENOENT' && !fs.existsSync(cwd))
212
+ throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' });
213
+ if (root.code === 'ENOENT')
214
+ throw Object.assign(new Error('git executable not found'), { code: 'git-missing' });
215
+ return { repo: null, commits: [] };
216
+ }
217
+ const repoRoot = root.stdout.split('\n')[0].trim();
218
+ if (!repoRoot)
219
+ return { repo: null, commits: [] };
220
+ const requested = typeof count === 'number' && Number.isFinite(count) ? Math.floor(count) : DEFAULT_COMMITS;
221
+ const limit = Math.min(Math.max(1, requested), MAX_COMMITS);
222
+ const result = await runGit(repoRoot, [
223
+ 'log',
224
+ `-n${String(limit)}`,
225
+ '--no-color',
226
+ '--date=short',
227
+ '--pretty=format:%h%x00%an%x00%ad%x00%ct%x00%D%x00%s',
228
+ ]);
229
+ if (!result.ok) {
230
+ // 空仓库 / 无 HEAD:不是错误,就是还没有提交。
231
+ return { repo: repoRoot, commits: [] };
232
+ }
233
+ const commits = result.stdout
234
+ .split('\n')
235
+ .filter((line) => line.length > 0)
236
+ .map((line) => {
237
+ const [sha, author, date, seconds, refs, subject] = line.split('\0');
238
+ const at = Number(seconds);
239
+ return {
240
+ sha: sha ?? '',
241
+ author: author ?? '',
242
+ date: date ?? '',
243
+ committedAt: Number.isFinite(at) ? at * 1000 : null,
244
+ refs: refs ? refs.split(', ').filter(Boolean) : [],
245
+ subject: subject ?? '',
246
+ };
247
+ });
248
+ return { repo: repoRoot, commits };
249
+ }
250
+ /** 这次提交的改动清单 + 元信息(只读 `git diff` / `git show`)。 */
251
+ /** 标签列表上限(超出的只报 truncated,不硬塞)。 */
252
+ export const TAGS_MAX = 100;
253
+ /**
254
+ * 读标签列表(`refs/tags`,按 creatordate 倒序 = 最新的在前)。
255
+ *
256
+ * 两处容易搞错、这里显式处理:
257
+ * - **附注 tag**(annotated)的 `%(objectname)` 是 **tag 对象**的 sha,不是它指向的提交;
258
+ * 要拿提交得用 `%(*objectname)`。轻量 tag 两者相同。列表里显示提交 sha(能对上“最近提交”)。
259
+ * - 主题用 `%(contents:subject)`:附注 tag 是 tag 说明的首行,轻量 tag 拿到的是提交标题 ——
260
+ * 两种情况都比只给一个名字有用;它理论上可以是多行,所以最后一个字段按“剩下全部”再取首行。
261
+ */
262
+ export async function readTags(cwd, count = TAGS_MAX) {
263
+ const root = await runGit(cwd, ['rev-parse', '--show-toplevel']);
264
+ if (!root.ok) {
265
+ if (root.killed)
266
+ throw Object.assign(new Error('git rev-parse timed out'), { code: 'timeout' });
267
+ if (root.code === 'ENOENT' && !fs.existsSync(cwd))
268
+ throw Object.assign(new Error('cwd does not exist'), { code: 'no-cwd' });
269
+ if (root.code === 'ENOENT')
270
+ throw Object.assign(new Error('git executable not found'), { code: 'git-missing' });
271
+ return { repo: null, tags: [], truncated: false };
272
+ }
273
+ const repoRoot = root.stdout.split('\n')[0].trim();
274
+ if (!repoRoot)
275
+ return { repo: null, tags: [], truncated: false };
276
+ const requested = typeof count === 'number' && Number.isFinite(count) ? Math.floor(count) : TAGS_MAX;
277
+ const limit = Math.min(Math.max(1, requested), TAGS_MAX);
278
+ // 多取一条用来判断“还有更多”(比事后猜“正好等于上限”准确)。
279
+ const result = await runGit(repoRoot, [
280
+ 'for-each-ref',
281
+ '--sort=-creatordate',
282
+ `--count=${String(limit + 1)}`,
283
+ '--format=%(refname:short)%00%(objecttype)%00%(objectname:short)%00%(*objectname:short)%00%(creatordate:short)%00%(creatordate:unix)%00%(contents:subject)',
284
+ 'refs/tags',
285
+ ]);
286
+ // 没有 tag 时 git 也返回 0(输出为空),不是错误。
287
+ if (!result.ok)
288
+ return { repo: repoRoot, tags: [], truncated: false };
289
+ const lines = result.stdout.split('\n').filter((line) => line.length > 0);
290
+ const tags = lines.map((line) => {
291
+ const parts = line.split('\0');
292
+ const [name, objecttype, objectname, peeled, date, seconds] = parts;
293
+ // 最后一个字段是主题:附注 tag 的说明可能是多行,这里只取首行。
294
+ const subject = parts.slice(6).join('\0').split('\n')[0] ?? '';
295
+ const at = Number(seconds);
296
+ const annotated = objecttype === 'tag';
297
+ // 附注 tag 要“剥一层”拿提交 sha;剥不出来(比如 tag 指向 blob/tree)就退回对象自身的 sha。
298
+ const sha = annotated && peeled ? peeled : objectname;
299
+ return {
300
+ name: name ?? '',
301
+ sha: sha ?? '',
302
+ annotated,
303
+ date: date ?? '',
304
+ committedAt: Number.isFinite(at) ? at * 1000 : null,
305
+ subject,
306
+ };
307
+ });
308
+ // creatordate 同一个秒内的先后 git 不保证(测试里三个 tag 常在同一秒建出来),
309
+ // 再按名字倒序定死 —— 否则界面上同批 tag 每次刷新都可能换位置。
310
+ //
311
+ // 顺序:**先排序再截断**。反过来的话截出来的是 git 的任意顺序(t1、t2),排序再好也没用。
312
+ tags.sort((left, right) => {
313
+ const diff = (right.committedAt ?? 0) - (left.committedAt ?? 0);
314
+ if (diff !== 0)
315
+ return diff;
316
+ return left.name < right.name ? 1 : left.name > right.name ? -1 : 0;
317
+ });
318
+ const truncated = tags.length > limit;
319
+ return { repo: repoRoot, tags: tags.slice(0, limit), truncated };
320
+ }
321
+ export async function readCommit(cwd, sha) {
322
+ const commit = await resolveCommit(cwd, sha);
323
+ if (!commit.repo)
324
+ return { repo: null, commit: null, files: [], truncated: false };
325
+ // 根提交没有父提交,改用 `show` 与空树比较;其余一律 diff 第一父提交。
326
+ const result = commit.base === null
327
+ ? await runGit(commit.repo, [
328
+ 'show',
329
+ '--format=',
330
+ '--no-color',
331
+ '--no-ext-diff',
332
+ '--name-status',
333
+ '-z',
334
+ '-M',
335
+ commit.sha,
336
+ ])
337
+ : await runGit(commit.repo, [
338
+ 'diff',
339
+ '--no-color',
340
+ '--no-ext-diff',
341
+ '--name-status',
342
+ '-z',
343
+ '-M',
344
+ commit.base,
345
+ commit.sha,
346
+ ]);
347
+ if (!result.ok) {
348
+ throw Object.assign(new Error(result.stderr.trim() || 'git diff failed'), {
349
+ code: result.killed ? 'timeout' : 'git-failed',
350
+ });
351
+ }
352
+ const { files, truncated } = parseNameStatus(result.stdout);
353
+ return {
354
+ repo: commit.repo,
355
+ commit: {
356
+ sha: commit.sha,
357
+ shortSha: commit.shortSha,
358
+ subject: commit.subject,
359
+ author: commit.author,
360
+ date: commit.date,
361
+ committedAt: commit.committedAt,
362
+ refs: commit.refs,
363
+ merge: commit.parents.length > 1,
364
+ },
365
+ files,
366
+ truncated,
367
+ };
368
+ }
369
+ /**
370
+ * 读这次提交里某个文件的 diff(相对第一父提交)。
371
+ *
372
+ * 重命名/复制把原路径一并作为 pathspec 传进去,否则 git 只看到"新增 + 删除"而不是
373
+ * 一次移动。未跟踪文件不属于提交,因此这里没有 `--no-index` 那条分支。
374
+ *
375
+ * @returns `{ repo, path, file, origPath, sha, rows, additions, deletions, hunks, binary, truncated }`。
376
+ */
377
+ export async function readCommitDiff(cwd, sha, file, origFile = null) {
378
+ const commit = await resolveCommit(cwd, sha);
379
+ if (!commit.repo) {
380
+ return {
381
+ repo: null,
382
+ sha,
383
+ file,
384
+ path: file,
385
+ origPath: origFile,
386
+ ...parseDiff(''),
387
+ truncated: false,
388
+ };
389
+ }
390
+ const pathspec = origFile === null || origFile === undefined || origFile === '' ? [file] : [file, origFile];
391
+ const result = commit.base === null
392
+ ? await runGit(commit.repo, [
393
+ 'show',
394
+ '--format=',
395
+ '--no-color',
396
+ '--no-ext-diff',
397
+ '-M',
398
+ commit.sha,
399
+ '--',
400
+ ...pathspec,
401
+ ])
402
+ : await runGit(commit.repo, [
403
+ 'diff',
404
+ '--no-color',
405
+ '--no-ext-diff',
406
+ '-M',
407
+ commit.base,
408
+ commit.sha,
409
+ '--',
410
+ ...pathspec,
411
+ ]);
412
+ if (!result.ok) {
413
+ throw Object.assign(new Error(result.stderr.trim() || 'git diff failed'), {
414
+ code: result.killed ? 'timeout' : 'git-failed',
415
+ });
416
+ }
417
+ const cut = truncateDiff(result.stdout);
418
+ const parsed = parseDiff(cut.text);
419
+ return {
420
+ repo: commit.repo,
421
+ sha: commit.sha,
422
+ shortSha: commit.shortSha,
423
+ file,
424
+ path: file,
425
+ origPath: origFile ?? null,
426
+ ...parsed,
427
+ truncated: parsed.truncated || cut.truncated,
428
+ };
429
+ }
430
+ export const STATUS_CACHE_MS = 1_500;
431
+ export const CACHE_MAX_ENTRIES = 32;
432
+ export const STATUS_CACHE = new Map();
433
+ export const INFLIGHT = new Map();
434
+ /** 带 TTL 缓存与在途合并的读取,面板轮询时不会反复跑 git。 */
435
+ export async function readStatusCached(cwd, now = Date.now()) {
436
+ const cached = STATUS_CACHE.get(cwd);
437
+ if (cached && now - cached.at < STATUS_CACHE_MS) {
438
+ return cached.value;
439
+ }
440
+ const inflight = INFLIGHT.get(cwd);
441
+ if (inflight)
442
+ return inflight;
443
+ const pending = readStatus(cwd).then((value) => {
444
+ INFLIGHT.delete(cwd);
445
+ STATUS_CACHE.set(cwd, { at: Date.now(), value });
446
+ if (STATUS_CACHE.size > CACHE_MAX_ENTRIES) {
447
+ const oldest = [...STATUS_CACHE.entries()].sort((left, right) => left[1].at - right[1].at)[0];
448
+ if (oldest)
449
+ STATUS_CACHE.delete(oldest[0]);
450
+ }
451
+ return value;
452
+ }, (error) => {
453
+ INFLIGHT.delete(cwd);
454
+ throw error;
455
+ });
456
+ INFLIGHT.set(cwd, pending);
457
+ return pending;
458
+ }