@shiqyka/dsh-git-graph 0.26.1 → 0.26.2

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.
Files changed (2) hide show
  1. package/index.js +1545 -1545
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -1,1545 +1,1545 @@
1
- // dsh-git-graph — server half
2
- // Registers HTTP routes under /git-graph:
3
- // GET /git-graph/api?op=<op>&repo=<path>[&hash=<sha>] -> JSON git data
4
- // GET /git-graph/index.html -> the visualizer page
5
- // Commands are executed with execFile (no shell), fixed argument lists only.
6
- import { execFile, execFileSync } from 'node:child_process'
7
- import { appendFileSync, copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
8
- import { basename, dirname, join, resolve } from 'node:path'
9
- import { tmpdir } from 'node:os'
10
- import { fileURLToPath } from 'node:url'
11
- import net from 'node:net'
12
-
13
- const here = dirname(fileURLToPath(import.meta.url))
14
- const webRoot = join(here, 'web')
15
-
16
- const MAX_GIT_BUFFER = 32 * 1024 * 1024
17
- const MAX_DIFF_CHARS = 240000
18
-
19
- /* Content-Security-Policy for the graph page and its two static assets.
20
- * The page used to be a single HTML file with everything inline, which forced
21
- * 'unsafe-inline' for both scripts and styles. Now that app.js and app.css are
22
- * separate files, 'self' suffices for each and the page needs no inline
23
- * allowances at all. Everything else stays locked down: no network origins
24
- * except this host, no frames, no plugins.
25
- * `img-src data: blob:` is still required — the UI builds small inline SVG/data
26
- * images, and the decorative background uses blob-free generated nodes. */
27
- const PAGE_CSP = [
28
- "default-src 'none'",
29
- "script-src 'self'",
30
- "style-src 'self'",
31
- 'img-src data: blob:',
32
- "connect-src 'self'",
33
- "font-src 'self'",
34
- "frame-ancestors 'self'",
35
- ].join('; ')
36
-
37
- /* ---- persisted UI preferences ------------------------------------------------
38
- * localStorage is scoped to the page's origin, and the DSH web port changes
39
- * between launches (e.g. 10426 -> 5828), which silently wiped every saved
40
- * preference — most visibly the branch filter, which then looked like it had
41
- * "reset itself". Storing the small, low-risk UI state on disk instead makes it
42
- * survive port changes and host restarts.
43
- *
44
- * Location: alongside the plugin (<plugin>/.state/prefs.json). That directory is
45
- * gitignored, so a dev checkout stays clean, and the file is per-install so two
46
- * DSH profiles do not fight over one file.
47
- * Scope: only UI preferences (branch filter, group visibility, fold state).
48
- * Never repository content, never credentials. */
49
- const STATE_DIR = join(here, '.state')
50
- const PREFS_FILE = join(STATE_DIR, 'prefs.json')
51
- const PREFS_MAX_BYTES = 256 * 1024
52
- const PREFS_ALLOWED_KEYS = new Set([
53
- 'gg2-bfilter', 'gg2-wdshow', 'gg2-wdfolded', 'gg2-wdgroupfolded', 'gg2-brfolded', 'gg2-theme',
54
- 'gg2-datefrom', 'gg2-dateto', 'gg2-flat', 'gg2-flat-reset',
55
- ])
56
-
57
- function readPrefs() {
58
- try {
59
- const st = statSync(PREFS_FILE)
60
- if (st.size > PREFS_MAX_BYTES) return {}
61
- const raw = readFileSync(PREFS_FILE, 'utf8')
62
- const obj = JSON.parse(raw)
63
- return obj && typeof obj === 'object' && !Array.isArray(obj) ? obj : {}
64
- } catch {
65
- return {}
66
- }
67
- }
68
-
69
- function writePrefs(patch) {
70
- const current = readPrefs()
71
- for (const [k, v] of Object.entries(patch || {})) {
72
- if (!PREFS_ALLOWED_KEYS.has(k)) continue // allow-list: ignore anything else
73
- if (v === null || v === undefined) delete current[k]
74
- else current[k] = v
75
- }
76
- mkdirSync(STATE_DIR, { recursive: true })
77
- writeFileSync(PREFS_FILE, JSON.stringify(current, null, 2), 'utf8')
78
- return current
79
- }
80
-
81
- export const name = 'git-graph'
82
- export const inject = ['webServer']
83
-
84
- function runGit(repo, args, maxBuffer = MAX_GIT_BUFFER, timeoutMs = 0) {
85
- return new Promise((resolve, reject) => {
86
- const opts = { maxBuffer, windowsHide: true }
87
- if (timeoutMs > 0) opts.timeout = timeoutMs
88
- execFile('git', ['-C', repo, ...args], opts, (err, stdout, stderr) => {
89
- if (err) {
90
- const detail = (stderr || err.message || '').toString().trim()
91
- reject(new Error(detail || 'git failed'))
92
- return
93
- }
94
- resolve(stdout.toString())
95
- })
96
- })
97
- }
98
-
99
- /* Fast local check for a git working tree: does `.git` exist (dir for a normal
100
- * clone, file for a linked worktree/submodule)? Equivalent to what
101
- * `git rev-parse --is-inside-work-tree` proves for the cases the repo list
102
- * cares about, but costs one statSync instead of a ~200ms process launch —
103
- * and this runs for every configured/known repo on each load. */
104
- function hasGitEntry(repo) {
105
- try { return !!statSync(join(repo, '.git')) } catch { return false }
106
- }
107
-
108
- async function isGitRepo(repo) {
109
- try {
110
- const out = await runGit(repo, ['rev-parse', '--is-inside-work-tree'])
111
- return out.trim() === 'true'
112
- } catch {
113
- return false
114
- }
115
- }
116
-
117
- /* Whether a string is a plain http(s) URL safe to hand to window.open().
118
- * Anything with another scheme (javascript:, data:, vbscript:, file:) — or a
119
- * string that is not a URL at all — is rejected. Scheme comparison is done on
120
- * the parsed URL, so "JAVASCRIPT:" and leading-whitespace variants are caught
121
- * the same way the browser would interpret them. */
122
- function isSafeWebUrl(value) {
123
- try {
124
- const u = new URL(String(value))
125
- return (u.protocol === 'http:' || u.protocol === 'https:') && !!u.hostname
126
- } catch {
127
- return false
128
- }
129
- }
130
-
131
- /* Resolve the .git directory for a work tree (handles linked worktrees and
132
- * submodules, where .git is a file containing "gitdir: <path>"). */
133
- function gitDirOf(repo) {
134
- let gitDir = join(repo, '.git')
135
- const st = statSync(gitDir)
136
- if (st.isFile()) {
137
- const m = /^gitdir:\s*(.+)$/m.exec(readFileSync(gitDir, 'utf8'))
138
- if (!m) return null
139
- const p = m[1].trim()
140
- gitDir = p.startsWith('/') || /^[A-Za-z]:/.test(p) ? p : resolve(repo, p)
141
- }
142
- return gitDir
143
- }
144
-
145
- /* Read every local/remote/tag ref straight off disk — zero process spawns.
146
- * Equivalent to the `for-each-ref` this used to shell out to, which cost a full
147
- * git launch (~870ms inside the DSH host process on this machine) on every load.
148
- *
149
- * Merges the two places refs live:
150
- * * <gitdir>/packed-refs (packed, sorted; lines "<sha> <refname>", plus
151
- * "^<sha>" peeled lines for annotated tags which are ignored here)
152
- * * <gitdir>/refs/**. (loose, overrides packed — a newer loose value wins)
153
- * Symbolic refs (e.g. refs/remotes/origin/HEAD -> refs/remotes/origin/master)
154
- * are dereferenced, matching what for-each-ref reports.
155
- * Returns null when the layout cannot be read, so callers can fall back to git. */
156
- function readRefsFromDisk(repo, headName) {
157
- try {
158
- const gitDir = gitDirOf(repo)
159
- if (!gitDir) return null
160
- const refs = new Map() // refname -> sha
161
- // 1) packed-refs
162
- try {
163
- const packed = readFileSync(join(gitDir, 'packed-refs'), 'utf8')
164
- for (const line of packed.split('\n')) {
165
- if (!line || line[0] === '#' || line[0] === '^') continue
166
- const sp = line.indexOf(' ')
167
- if (sp <= 0) continue
168
- const sha = line.slice(0, sp).trim()
169
- const name = line.slice(sp + 1).trim()
170
- if (sha && name) refs.set(name, sha)
171
- }
172
- } catch { /* no packed-refs: all refs are loose */ }
173
- // 2) loose refs override packed values.
174
- // Symbolic refs are collected into `pending` and resolved in a SECOND pass,
175
- // because the target may itself be a loose ref that has not been read yet
176
- // (e.g. refs/remotes/origin/HEAD -> refs/remotes/origin/master, where the
177
- // packed value of origin/master is stale and the loose one wins). Resolving
178
- // them inline would silently report the stale packed sha.
179
- const pending = [] // [refname, targetRef]
180
- const walk = (dir, prefix) => {
181
- let entries
182
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
183
- for (const e of entries) {
184
- const full = join(dir, e.name)
185
- const name = prefix + '/' + e.name
186
- if (e.isDirectory()) { walk(full, name); continue }
187
- let content
188
- try { content = readFileSync(full, 'utf8').trim() } catch { continue }
189
- if (content.startsWith('ref:')) {
190
- pending.push([name, content.slice(4).trim()])
191
- continue
192
- }
193
- if (/^[0-9a-f]{40,64}$/i.test(content)) refs.set(name, content)
194
- }
195
- }
196
- for (const base of ['refs/heads', 'refs/remotes', 'refs/tags']) {
197
- walk(join(gitDir, base), base)
198
- }
199
- // second pass: resolve symbolic refs (bounded, so a cycle cannot hang us)
200
- for (const [name, target] of pending) {
201
- let cur = target
202
- for (let hop = 0; hop < 10; hop++) {
203
- const direct = refs.get(cur)
204
- if (direct) { refs.set(name, direct); break }
205
- const next = pending.find((p) => p[0] === cur)
206
- if (!next) break
207
- cur = next[1]
208
- }
209
- }
210
- const branches = []
211
- for (const [refname, sha] of refs) {
212
- branches.push({
213
- refname,
214
- kind: refname.startsWith('refs/tags/') ? 'tag' : refname.startsWith('refs/remotes/') ? 'remote' : 'local',
215
- name: refname.replace(/^refs\/(heads|remotes|tags)\//, ''),
216
- sha,
217
- // the checked-out branch, same meaning as for-each-ref's %(HEAD)
218
- head: refname === 'refs/heads/' + headName,
219
- })
220
- }
221
- if (!branches.length) return null
222
- return branches
223
- } catch {
224
- return null
225
- }
226
- }
227
-
228
- /* Read the current branch name straight from .git/HEAD (no process spawn).
229
- * Equivalent to `git symbolic-ref -q --short HEAD`: a detached HEAD yields
230
- * "HEAD", and any failure falls back to "HEAD" as the old implementation did.
231
- * Handles linked worktrees / submodules, where .git is a file containing
232
- * "gitdir: <path>". */
233
- function readHeadName(repo) {
234
- try {
235
- let gitDir = join(repo, '.git')
236
- let st = statSync(gitDir)
237
- if (st.isFile()) {
238
- const m = /^gitdir:\s*(.+)$/m.exec(readFileSync(gitDir, 'utf8'))
239
- if (!m) return 'HEAD'
240
- const p = m[1].trim()
241
- gitDir = p.startsWith('/') || /^[A-Za-z]:/.test(p) ? p : resolve(repo, p)
242
- }
243
- const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim()
244
- if (head.startsWith('ref:')) {
245
- const ref = head.slice(4).trim() // refs/heads/master
246
- const short = ref.replace(/^refs\/heads\//, '')
247
- return short || 'HEAD'
248
- }
249
- return 'HEAD' // detached
250
- } catch {
251
- return 'HEAD'
252
- }
253
- }
254
-
255
- /* ===================== self-update ===================== */
256
- // Checks the GitHub repo for newer tags and can replace this plugin's files
257
- // from a codeload zip. No API key needed: version lookup uses `git ls-remote
258
- // --tags`, download uses https://codeload.github.com (zip, extracted by
259
- // bsdtar which ships with Windows 10+ / macOS / most Linux distros).
260
- const UPDATE_REPO = 'siqyka/dsh-git-graph'
261
- const UPDATE_GH = `https://github.com/${UPDATE_REPO}`
262
-
263
- function localVersion() {
264
- try {
265
- const pkg = JSON.parse(readFileSync(join(here, 'package.json'), 'utf8'))
266
- return pkg.version || ''
267
- } catch { return '' }
268
- }
269
-
270
- /* Whether this plugin's own directory lives inside a git work tree (a
271
- * `link:`/dev checkout mount). Self-update must not overwrite files the user
272
- * owns through git, so in that mode checkupdate reports managed:'git' and the
273
- * UI hides the update button in favor of `git pull`. Cached after first call. */
274
- let gitManagedPromise = null
275
- function gitManaged() {
276
- if (!gitManagedPromise) {
277
- gitManagedPromise = new Promise((res) => {
278
- execFile('git', ['-C', here, 'rev-parse', '--is-inside-work-tree'], { windowsHide: true }, (err, stdout) => {
279
- res(!err && String(stdout).trim() === 'true')
280
- })
281
- })
282
- }
283
- return gitManagedPromise
284
- }
285
-
286
- /** Compare dotted versions: 1 if a>b, -1 if a<b, 0 if equal. */
287
- function cmpVer(a, b) {
288
- const pa = String(a).replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0)
289
- const pb = String(b).replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0)
290
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
291
- const x = pa[i] || 0
292
- const y = pb[i] || 0
293
- if (x > y) return 1
294
- if (x < y) return -1
295
- }
296
- return 0
297
- }
298
-
299
- /** Extra `git -c` args for network commands. Honors the user's global
300
- * http(s).proxy / sslBackend / http.version config; if no proxy is set it
301
- * auto-detects common local proxy ports (Clash/mihomo 7897, Clash 7890,
302
- * v2ray 10809, socks 1080, etc.) and falls back to OpenSSL + HTTP/1.1
303
- * (Windows schannel often fails against GitHub on CN networks). */
304
- const PROXY_PORTS = [7897, 7890, 10809, 1080, 8888, 7891]
305
- function portOpen(port) {
306
- return new Promise((resolve) => {
307
- const sock = new net.Socket()
308
- sock.setTimeout(400)
309
- sock.once('connect', () => { sock.destroy(); resolve(true) })
310
- sock.once('timeout', () => { sock.destroy(); resolve(false) })
311
- sock.once('error', () => { sock.destroy(); resolve(false) })
312
- sock.connect(port, '127.0.0.1')
313
- })
314
- }
315
-
316
- /** Resolve a usable proxy for GitHub: global git config wins, then a
317
- * listening local proxy port. Returns '' when none found. */
318
- async function resolveProxy() {
319
- const readGlobal = (k) => {
320
- try {
321
- const out = execFileSync('git', ['config', '--global', '--get', k], { encoding: 'utf8', windowsHide: true })
322
- return out.trim()
323
- } catch { return '' }
324
- }
325
- const fromConfig = readGlobal('http.proxy') || readGlobal('https.proxy')
326
- if (fromConfig) return fromConfig
327
- for (const port of PROXY_PORTS) {
328
- if (await portOpen(port)) return `http://127.0.0.1:${port}`
329
- }
330
- return ''
331
- }
332
-
333
- /** Extra `git -c` args for network commands: proxy (global config or
334
- * auto-detected local port) + OpenSSL backend + HTTP/1.1 (Windows
335
- * schannel often fails against GitHub on CN networks). */
336
- async function gitNetArgs() {
337
- const extra = []
338
- const readGlobal = (k) => {
339
- try {
340
- const out = execFileSync('git', ['config', '--global', '--get', k], { encoding: 'utf8', windowsHide: true })
341
- return out.trim()
342
- } catch { return '' }
343
- }
344
- const proxy = await resolveProxy()
345
- if (proxy) extra.push('-c', 'http.proxy=' + proxy, '-c', 'https.proxy=' + proxy)
346
- const backend = readGlobal('http.sslBackend') || 'openssl'
347
- extra.push('-c', 'http.sslBackend=' + backend)
348
- const ver = readGlobal('http.version') || 'HTTP/1.1'
349
- extra.push('-c', 'http.version=' + ver)
350
- return extra
351
- }
352
-
353
- async function latestTag() {
354
- const out = await runGit(here, [...await gitNetArgs(), 'ls-remote', '--tags', UPDATE_GH], MAX_GIT_BUFFER, 60000)
355
- const tags = new Set()
356
- for (const line of String(out).split('\n')) {
357
- const m = line.match(/refs\/tags\/(v?\d+\.\d+(?:\.\d+)?)(\^\{\})?$/)
358
- if (m && !m[2]) tags.add(m[1])
359
- }
360
- if (!tags.size) return ''
361
- return [...tags].sort((a, b) => cmpVer(b, a))[0]
362
- }
363
-
364
- /** Download + extract + swap this plugin's files with the given tag.
365
- * Uses `git clone --depth 1 --branch <tag>` so the download rides the same
366
- * proxy / sslBackend / http.version config as every other git command. */
367
- async function applyUpdate(tag) {
368
- if (!/^v?\d+\.\d+(?:\.\d+)?$/.test(tag)) throw new Error('bad-tag')
369
- const tmp = mkdtempSync(join(tmpdir(), 'ggupd-'))
370
- const srcDir = join(tmp, 'src')
371
- try {
372
- await runGit(here, [...await gitNetArgs(), 'clone', '--depth', '1', '--branch', tag, UPDATE_GH, srcDir], MAX_GIT_BUFFER, 180000)
373
- if (!statSync(join(srcDir, 'index.js'), { throwIfNoEntry: false })) throw new Error('missing-index')
374
- if (!statSync(join(srcDir, 'web', 'index.html'), { throwIfNoEntry: false })) throw new Error('missing-web')
375
- // The page pulls these two in; a release that ships index.html without them
376
- // would swap in a blank screen, so refuse before touching the install.
377
- if (!statSync(join(srcDir, 'web', 'app.css'), { throwIfNoEntry: false })) throw new Error('missing-web-css')
378
- if (!statSync(join(srcDir, 'web', 'app.js'), { throwIfNoEntry: false })) throw new Error('missing-web-js')
379
-
380
- // backup the current install, then swap (skip the cloned .git dir).
381
- // The backup lives next to the plugin dir (sibling), never inside it,
382
- // so it survives clearTree and is easy for users to find.
383
- const bak = join(dirname(here), basename(here) + '.bak-' + Date.now())
384
- try {
385
- copyTree(here, bak)
386
- clearTree(here)
387
- copyTree(srcDir, here, true)
388
- } catch (err) {
389
- // roll back to the backup
390
- try { clearTree(here); copyTree(bak, here) } catch { /* best effort */ }
391
- throw err
392
- }
393
- // keep only the newest backup
394
- pruneBackups()
395
- } finally {
396
- // Windows: cloned .git dirs may still be held by AV/antivirus scanners or
397
- // lingering file handles, so retry; and never let cleanup failure mask a
398
- // successful update (the swap above already happened).
399
- try {
400
- rmSync(tmp, { recursive: true, force: true, maxRetries: 6, retryDelay: 250 })
401
- } catch {
402
- scheduleCleanup(tmp)
403
- }
404
- }
405
- }
406
-
407
- /** Best-effort late cleanup of a temp dir Windows refused to delete yet
408
- * (handles usually release within a few seconds). Never throws. */
409
- function scheduleCleanup(dir) {
410
- let tries = 0
411
- const attempt = () => {
412
- tries++
413
- try {
414
- rmSync(dir, { recursive: true, force: true })
415
- } catch {
416
- if (tries < 12) setTimeout(attempt, 1000)
417
- }
418
- }
419
- setTimeout(attempt, 1500)
420
- }
421
-
422
- function copyTree(src, dst, skipDotGit) {
423
- const cp = (s, d) => {
424
- const st = statSync(s, { throwIfNoEntry: false })
425
- if (!st) return
426
- if (st.isDirectory()) {
427
- if (skipDotGit && s.endsWith('.git')) return
428
- mkdirSync(d, { recursive: true })
429
- for (const name of readdirSync(s)) cp(join(s, name), join(d, name))
430
- } else if (st.isFile()) {
431
- copyFileSync(s, d)
432
- }
433
- }
434
- cp(src, dst)
435
- }
436
- function clearTree(dir) {
437
- if (!statSync(dir, { throwIfNoEntry: false })) return
438
- for (const name of readdirSync(dir)) {
439
- if (name.startsWith('.bak-')) continue
440
- rmSync(join(dir, name), { recursive: true, force: true })
441
- }
442
- }
443
- function pruneBackups() {
444
- const parent = dirname(here)
445
- const base = basename(here)
446
- const prefix = base + '.bak-'
447
- const baks = readdirSync(parent).filter((n) => n.startsWith(prefix)).sort()
448
- for (const b of baks.slice(0, -1)) rmSync(join(parent, b), { recursive: true, force: true })
449
- }
450
-
451
- /** Walk upward from `start` until a directory containing `.git` is found. */
452
- function findGitRoot(start) {
453
- let dir = String(start).replace(/\\/g, '/').replace(/\/+$/, '') || '/'
454
- for (let i = 0; i < 24; i++) {
455
- try {
456
- const p = join(dir, '.git')
457
- const st = statSync(p)
458
- if (st.isDirectory() || st.isFile()) return dir
459
- } catch { /* keep walking */ }
460
- const parent = dirname(dir)
461
- if (parent === dir) return null
462
- dir = parent
463
- }
464
- return null
465
- }
466
-
467
- /** Parse `git log --format=...%x1f...%d` lines into structured commit rows. */
468
- function parseLog(stdout) {
469
- const rows = []
470
- for (const line of stdout.split('\n')) {
471
- if (!line) continue
472
- const [hash, parents, author, email, date, subject, refs] = line.split('\x1f')
473
- rows.push({
474
- hash,
475
- parents: parents ? parents.split(' ') : [],
476
- author,
477
- email,
478
- date,
479
- subject,
480
- refs: parseRefs(refs),
481
- })
482
- }
483
- return rows
484
- }
485
-
486
- /** Parse git refs decoration like " (HEAD -> master, tag: v1, origin/dev)". */
487
- function parseRefs(raw) {
488
- const refs = []
489
- if (!raw) return refs
490
- const inner = raw.replace(/^\(/, '').replace(/\)$/, '').trim()
491
- if (!inner) return refs
492
- for (const part of inner.split(',')) {
493
- const item = part.trim()
494
- if (!item) continue
495
- if (item.startsWith('tag: ')) {
496
- refs.push({ kind: 'tag', name: item.slice(5).trim() })
497
- } else if (item.includes(' -> ')) {
498
- const [, target] = item.split(' -> ')
499
- refs.push({ kind: 'branch', name: target.trim(), head: item.startsWith('HEAD') })
500
- } else {
501
- refs.push({ kind: 'branch', name: item })
502
- }
503
- }
504
- return refs
505
- }
506
-
507
- /**
508
- * Parse `git status --porcelain=v1 -z` output. Returns [{x, y, path, oldPath?}].
509
- * In -z mode rename/copy entries emit two records: "XY <new path>" then "<old path>".
510
- */
511
- function parsePorcelainZ(buf) {
512
- const records = []
513
- const parts = buf.split('\0')
514
- for (let i = 0; i < parts.length; i++) {
515
- const rec = parts[i]
516
- if (!rec || rec.length < 3) continue
517
- const x = rec[0]
518
- const y = rec[1]
519
- const path = rec.slice(3)
520
- if ((x === 'R' || x === 'C') && i + 1 < parts.length && parts[i + 1]) {
521
- records.push({ x, y, path, oldPath: parts[i + 1] })
522
- i++
523
- continue
524
- }
525
- records.push({ x, y, path })
526
- }
527
- return records
528
- }
529
-
530
- /**
531
- * Parse `git diff --numstat -z` output into a path -> {added, deleted} map.
532
- * Normal entries are "added\tdeleted\t<path>"; rename entries are
533
- * "added\tdeleted" + "\0" + "<old>" + "\0" + "<new>" (keyed by the new path);
534
- * binary entries use "-" for both counts.
535
- */
536
- function parseNumstatZ(buf) {
537
- const map = new Map()
538
- const parts = buf.split('\0')
539
- for (let i = 0; i < parts.length; i++) {
540
- const rec = parts[i]
541
- if (!rec) continue
542
- const fields = rec.split('\t')
543
- if (fields.length < 2) continue
544
- const added = fields[0]
545
- const deleted = fields[1]
546
- let key
547
- if (fields.length >= 3) {
548
- key = fields[2]
549
- } else {
550
- // rename: "n\tn" then "<old>" then "<new>"
551
- const old = i + 1 < parts.length ? parts[i + 1] : ''
552
- const next = i + 2 < parts.length ? parts[i + 2] : ''
553
- key = next || old
554
- i += 2
555
- }
556
- if (key) map.set(key, { added, deleted })
557
- }
558
- return map
559
- }
560
-
561
- export function apply(ctx, config = {}) {
562
- const configured = []
563
- if (config.repo) configured.push(String(config.repo))
564
- if (Array.isArray(config.repos)) configured.push(...config.repos.map(String))
565
- const normalize = (p) => String(p).replace(/[\\/]+$/, '')
566
- const allowed = [...new Set(configured.map(normalize))].filter(Boolean)
567
- // Workspace-discovered repos (from the repos op) are accepted for reads too.
568
- // `recentKeys` holds case-folded keys so Windows paths differing only in case
569
- // still match (e.g. "C:/Work" vs "c:/work").
570
- const recentRepos = new Set()
571
- const recentKeys = new Set()
572
- const normKey = (p) => {
573
- const s = String(p).replace(/\\/g, '/').replace(/\/+$/, '')
574
- return process.platform === 'win32' ? s.toLowerCase() : s
575
- }
576
- const repoAllowed = (p) => {
577
- const k = normKey(p)
578
- if (allowed.some((a) => normKey(a) === k)) return true
579
- return recentKeys.has(k)
580
- }
581
- const addRecent = (p) => { recentRepos.add(p); recentKeys.add(normKey(p)) }
582
-
583
- ctx.webServer.register({
584
- kind: 'prefix',
585
- path: '/git-graph',
586
- handler: async (req, res) => {
587
- /* Baseline hardening headers on every response (JSON and HTML).
588
- * nosniff stops a mislabeled response being sniffed as script; the page
589
- * renders inside the plugin iframe, so no-store also keeps a cached
590
- * snapshot of repo data from lingering in the browser cache. */
591
- const BASE_HEADERS = {
592
- 'cache-control': 'no-store',
593
- 'x-content-type-options': 'nosniff',
594
- }
595
- const sendJson = (obj, status = 200) => {
596
- const body = JSON.stringify(obj)
597
- res.writeHead(status, {
598
- ...BASE_HEADERS,
599
- 'content-type': 'application/json; charset=utf-8',
600
- })
601
- res.end(body)
602
- }
603
- try {
604
- const url = new URL(req.url, 'http://localhost')
605
- const pathname = url.pathname
606
-
607
- if (pathname === '/git-graph/' || pathname === '/git-graph/index.html') {
608
- res.writeHead(200, {
609
- ...BASE_HEADERS,
610
- 'content-type': 'text/html; charset=utf-8',
611
- // The page is same-origin with the host GUI and only talks to this
612
- // API. The stylesheet and the app script are separate files served
613
- // from here, so 'self' covers both and 'unsafe-inline' is no longer
614
- // needed for either — it was only required back when the whole app
615
- // was one inline <script>.
616
- 'content-security-policy': PAGE_CSP,
617
- })
618
- res.end(readFileSync(join(webRoot, 'index.html')))
619
- return
620
- }
621
-
622
- /* Static assets for the page: app.css and app.js. An explicit
623
- * allow-list rather than a directory server — `web/` holds only these
624
- * three files today, and an allow-list cannot be tricked into serving
625
- * something else that ends up in there later. The CSP header goes on
626
- * these responses too: for a normal subresource the document's policy
627
- * governs, but a direct navigation to either URL makes it the top-level
628
- * document, and then the header is the only thing constraining it. */
629
- if (pathname === '/git-graph/app.css' || pathname === '/git-graph/app.js') {
630
- const isCss = pathname.endsWith('.css')
631
- res.writeHead(200, {
632
- ...BASE_HEADERS,
633
- 'content-type': isCss ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8',
634
- 'content-security-policy': PAGE_CSP,
635
- })
636
- res.end(readFileSync(join(webRoot, isCss ? 'app.css' : 'app.js')))
637
- return
638
- }
639
-
640
- if (pathname !== '/git-graph/api') {
641
- sendJson({ ok: false, error: 'not-found' }, 404)
642
- return
643
- }
644
-
645
- /* ---- CSRF guard ---------------------------------------------------
646
- * Without this, any web page could destroy work by pointing an <img> at
647
- * `/git-graph/api?op=gitop&action=discard&...`: the browser attaches the
648
- * request, and GET-only handlers ran it. Three independent checks:
649
- * 1. writes must be POST (an <img>/<a> can never send POST)
650
- * 2. Origin, when present, must match this host
651
- * 3. Referer, when present, must match this host
652
- * Requests without Origin/Referer are allowed for reads so that
653
- * non-browser callers (curl, tests, the plugin's own startup) keep
654
- * working; writes additionally require POST, which browsers cannot
655
- * forge cross-site without a CORS preflight. */
656
- const WRITE_OPS = new Set(['gitop', 'autoupdate', 'addrepo', 'prefswrite'])
657
- const reqOp = url.searchParams.get('op') || 'graph'
658
- const isWrite = WRITE_OPS.has(reqOp)
659
- const selfHost = String(req.headers.host || '')
660
- const sameHost = (value) => {
661
- if (!value) return null // absent: caller decides
662
- try {
663
- const u = new URL(value, 'http://' + selfHost)
664
- return u.host === selfHost
665
- } catch { return false }
666
- }
667
- const originOk = sameHost(req.headers.origin)
668
- const refererOk = sameHost(req.headers.referer)
669
- if (originOk === false || refererOk === false) {
670
- sendJson({ ok: false, error: 'cross-origin-blocked' }, 403)
671
- return
672
- }
673
- if (isWrite && req.method !== 'POST') {
674
- sendJson({ ok: false, error: 'method-not-allowed', hint: 'write operations require POST' }, 405)
675
- return
676
- }
677
-
678
- const op = url.searchParams.get('op') || 'graph'
679
-
680
- if (op === 'prefs' || op === 'prefswrite') {
681
- // UI preference store (see readPrefs/writePrefs). Repo-independent.
682
- if (op === 'prefs') {
683
- sendJson({ ok: true, prefs: readPrefs() })
684
- return
685
- }
686
- // prefswrite: `set` is a JSON object of allow-listed keys to persist.
687
- const raw = url.searchParams.get('set') || ''
688
- if (!raw) {
689
- sendJson({ ok: false, error: 'missing-set' }, 400)
690
- return
691
- }
692
- let patch
693
- try {
694
- patch = JSON.parse(raw)
695
- } catch {
696
- sendJson({ ok: false, error: 'bad-json' }, 400)
697
- return
698
- }
699
- if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
700
- sendJson({ ok: false, error: 'bad-shape' }, 400)
701
- return
702
- }
703
- try {
704
- const prefs = writePrefs(patch)
705
- sendJson({ ok: true, prefs })
706
- } catch (err) {
707
- sendJson({ ok: false, error: String((err && err.message) || err) }, 500)
708
- }
709
- return
710
- }
711
- if (op === 'repos') {
712
- // The current session's workspace (ws) is preferred when a git work
713
- // tree is found at or above it; configured repos follow as fallbacks.
714
- // `add` re-registers repos the user picked via the folder picker
715
- // (they are persisted on the client and re-sent on every load,
716
- // because the in-memory allow-set resets when dsh restarts).
717
- for (const a of url.searchParams.getAll('add')) {
718
- const root = findGitRoot(a)
719
- if (root) addRecent(root)
720
- }
721
- // Drop configured paths that are not working git trees, so a stale
722
- // example config never poisons the repo list (not-a-git-repo).
723
- // This uses the statSync check: a process launch per configured repo
724
- // made the endpoint cost ~200ms each for no extra certainty.
725
- const configuredOk = []
726
- for (const r of allowed) {
727
- if (hasGitEntry(r)) configuredOk.push(r)
728
- }
729
- // Same for user-added repos: a folder removed from disk must not stay
730
- // in the switcher forever (it would become the remembered current one).
731
- // Light existence check (.git entry) + case-folded dedup (recentKeys).
732
- const recentOk = []
733
- for (const r of recentRepos) {
734
- if (hasGitEntry(r) && !recentOk.some((x) => normKey(x) === normKey(r))) recentOk.push(r)
735
- }
736
- const ws = normalize(url.searchParams.get('ws') || '')
737
- let wsRoot = null
738
- let wsIsGit = false
739
- if (ws) {
740
- const root = findGitRoot(ws)
741
- if (root) {
742
- wsRoot = root
743
- wsIsGit = true
744
- }
745
- }
746
- const repos = []
747
- if (wsRoot) repos.push(wsRoot)
748
- for (const r of configuredOk) if (!repos.some((x) => normKey(x) === normKey(r))) repos.push(r)
749
- for (const r of recentOk) if (!repos.some((x) => normKey(x) === normKey(r))) repos.push(r)
750
- for (const r of repos) addRecent(r)
751
- let current = null
752
- if (wsIsGit) current = wsRoot
753
- else current = repos[0] ?? null // first configured/user repo (ws non-git or absent)
754
- sendJson({ ok: true, repos, current, ws, wsRoot, wsIsGit })
755
- return
756
- }
757
-
758
- if (op === 'findrepos') {
759
- // One-click discovery for the empty-state button「在当前对话目录下查找 Git 仓库」:
760
- // find the git root at/above the workspace; when there is none, shallow-scan
761
- // subdirectories (depth <= 2, skipping heavy/hidden entries) for git work
762
- // trees. Read-only: statSync/readdirSync only, no shell. Bounded work:
763
- // MAX_DIRS enumerated, MAX_RESULTS returned.
764
- const ws = normalize(url.searchParams.get('ws') || '')
765
- let wsStat = null
766
- try { wsStat = statSync(ws) } catch { /* absent */ }
767
- if (!ws || !wsStat || !wsStat.isDirectory()) {
768
- sendJson({ ok: false, error: 'bad-ws' }, 400)
769
- return
770
- }
771
- const MAX_DIRS = 300
772
- const MAX_RESULTS = 20
773
- const out = []
774
- const seen = new Set()
775
- const pushRoot = (dir) => {
776
- let root = null
777
- try { root = findGitRoot(dir) } catch { /* ignore */ }
778
- if (!root) return
779
- const k = normKey(root)
780
- if (seen.has(k)) return
781
- seen.add(k)
782
- out.push(root)
783
- }
784
- const depthOf = (p) => {
785
- const a = String(ws).replace(/\\/g, '/').split('/')
786
- const b = String(p).replace(/\\/g, '/').split('/')
787
- let i = 0
788
- while (i < a.length && i < b.length && a[i] === b[i]) i++
789
- return b.length - i
790
- }
791
- pushRoot(ws)
792
- if (out.length === 0) {
793
- // Shallow scan: enumerate ws and its subdirectories; check every
794
- // enumerated dir plus one more level for git roots.
795
- const queue = [ws]
796
- let dirs = 0
797
- while (queue.length > 0 && dirs < MAX_DIRS && out.length < MAX_RESULTS) {
798
- const dir = queue.shift()
799
- let entries = []
800
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { continue }
801
- for (const e of entries) {
802
- if (!e.isDirectory()) continue
803
- const name = e.name
804
- if (name === '.git' || name === 'node_modules' || name.startsWith('.') ||
805
- name === '$RECYCLE.BIN' || name === 'System Volume Information') continue
806
- const full = join(dir, name)
807
- if (++dirs > MAX_DIRS) break
808
- pushRoot(full)
809
- if (out.length >= MAX_RESULTS) break
810
- if (depthOf(full) < 2) queue.push(full)
811
- }
812
- }
813
- }
814
- sendJson({ ok: true, ws, repos: out })
815
- return
816
- }
817
-
818
- if (op === 'listdir') {
819
- // Directory browser for the "打开文件夹" picker. Read-only; lists only
820
- // subdirectories (never files) with a per-entry git marker ("git"
821
- // = the folder contains a .git entry, so it is a repo or a submodule).
822
- const hasDotGit = (dir) => {
823
- try { return !!statSync(join(dir, '.git')) } catch { return false }
824
- }
825
- const listSubdirs = (dir) => {
826
- let entries = []
827
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return { dirs: [], truncated: false, readable: false } }
828
- const names = entries
829
- .filter((e) => e.isDirectory() && e.name !== '.git')
830
- .map((e) => e.name)
831
- .sort((a, b) => a.localeCompare(b))
832
- const limit = 500
833
- const dirs = names.slice(0, limit).map((name) => {
834
- const full = join(dir, name)
835
- return { name, path: full.replace(/\\/g, '/'), git: hasDotGit(full) }
836
- })
837
- return { dirs, truncated: names.length > limit, readable: true }
838
- }
839
- let p = String(url.searchParams.get('path') || '').trim().replace(/\\/g, '/')
840
- if (!p) {
841
- if (process.platform === 'win32') {
842
- // No path yet: list the drive letters the picker can start from.
843
- const drives = []
844
- for (let c = 65; c <= 90; c++) {
845
- const d = String.fromCharCode(c) + ':/'
846
- try { if (statSync(d).isDirectory()) drives.push(d) } catch { /* drive absent */ }
847
- }
848
- sendJson({ ok: true, path: '', parent: null, drives, dirs: [], root: null, truncated: false, readable: true })
849
- return
850
- }
851
- p = '/'
852
- }
853
- if (/^[A-Za-z]:$/.test(p)) p += '/' // bare drive letter ("C:") -> "C:/"
854
- let abs
855
- try { abs = resolve(p) } catch { sendJson({ ok: false, error: 'bad-path', path: p }, 400); return }
856
- let st
857
- try { st = statSync(abs) } catch { sendJson({ ok: false, error: 'not-found', path: p }, 404); return }
858
- if (!st.isDirectory()) { sendJson({ ok: false, error: 'not-a-directory', path: p }, 400); return }
859
- const norm = abs.replace(/\\/g, '/')
860
- const par = dirname(norm)
861
- const { dirs, truncated, readable } = listSubdirs(abs)
862
- let root = null
863
- try { root = findGitRoot(abs) } catch { /* ignore */ }
864
- sendJson({ ok: true, path: norm, parent: par === norm ? null : par, dirs, root, truncated, readable })
865
- return
866
- }
867
-
868
- if (op === 'addrepo') {
869
- // Register a folder (or any folder inside one) as an openable repo:
870
- // finds the enclosing git work tree, like the workspace discovery,
871
- // then verifies it really is a working tree (a bare .git dir alone
872
- // is not enough — it could be empty/corrupted).
873
- const p = String(url.searchParams.get('path') || '').trim()
874
- if (!p) { sendJson({ ok: false, error: 'missing-path' }, 400); return }
875
- let abs
876
- try { abs = resolve(p) } catch { sendJson({ ok: false, error: 'bad-path', path: p }, 400); return }
877
- let st
878
- try { st = statSync(abs) } catch { sendJson({ ok: false, error: 'not-found', path: p }, 404); return }
879
- if (!st.isDirectory()) { sendJson({ ok: false, error: 'not-a-directory', path: p }, 400); return }
880
- const root = findGitRoot(abs)
881
- if (!root || !(await isGitRepo(root))) { sendJson({ ok: false, error: 'not-a-git-repo', path: p }, 400); return }
882
- addRecent(root)
883
- sendJson({ ok: true, repo: root })
884
- return
885
- }
886
-
887
- if (op === 'checkupdate') {
888
- // Compare the installed version against the latest GitHub tag.
889
- // Read-only; the client decides whether to offer an update.
890
- // Even when the network fails we still return the installed
891
- // version so the UI can always display it. Handled before the repo
892
- // gate (it needs no repository); when the plugin is mounted from a
893
- // git checkout the update is always suppressed.
894
- const current = localVersion()
895
- const managed = (await gitManaged()) ? 'git' : undefined
896
- try {
897
- const latest = await latestTag()
898
- const hasUpdate = !managed && latest ? cmpVer(latest, current) > 0 : false
899
- sendJson({ ok: true, current, latest: managed ? '' : latest, hasUpdate, managed })
900
- } catch (err) {
901
- sendJson({ ok: true, current, latest: '', hasUpdate: false, network: false, managed, error: String((err && err.message) || err) })
902
- }
903
- return
904
- }
905
-
906
- if (op === 'autoupdate') {
907
- // Download the given tag from GitHub and swap this plugin's files.
908
- // The caller must confirm first; a restart of dsh web is required
909
- // afterwards (the running process keeps the old code in memory).
910
- // Also repo-independent: handled before the repo gate.
911
- if (await gitManaged()) {
912
- sendJson({ ok: false, error: 'git-managed' }, 409)
913
- return
914
- }
915
- const tag = url.searchParams.get('tag')
916
- if (!tag) {
917
- sendJson({ ok: false, error: 'missing-tag' }, 400)
918
- return
919
- }
920
- try {
921
- await applyUpdate(tag)
922
- sendJson({ ok: true, restart: true })
923
- } catch (err) {
924
- sendJson({ ok: false, error: String((err && err.message) || err) }, 500)
925
- }
926
- return
927
- }
928
-
929
- let repo = url.searchParams.get('repo') || allowed[0]
930
- const repoNorm = normalize(repo || '')
931
- if (!repoNorm || !repoAllowed(repoNorm)) {
932
- // Do not echo the allow-list: it would hand any unauthenticated caller
933
- // a map of absolute paths on this machine. The count is enough for the
934
- // UI to tell "no repos" apart from "wrong repo".
935
- sendJson({ ok: false, error: 'repo-not-allowed', count: allowed.length + recentRepos.size }, 403)
936
- return
937
- }
938
- repo = repoNorm
939
-
940
- // Sanity: reject paths that are not existing git work trees.
941
- // This runs on EVERY api call, so it must not launch a process: a
942
- // `git rev-parse` here cost ~217ms per request on a machine where
943
- // process creation is slow — it doubled the cost of every endpoint
944
- // (show went from one spawn to two, filediff from two to three).
945
- // A `.git` entry check answers the same question for the cases that
946
- // matter here, because the path already passed the allow-list check.
947
- if (!hasGitEntry(repo)) {
948
- sendJson({ ok: false, error: 'not-a-git-repo', repo }, 400)
949
- return
950
- }
951
-
952
- switch (op) {
953
- case 'graph': {
954
- // Spawn count matters a lot here: on Windows each `git` launch costs
955
- // ~200ms (AV real-time scanning serializes process creation), so the
956
- // old head + rev-list --count + log triple cost ~670ms. Both extra
957
- // commands are now folded into the log call:
958
- // * head -> `log -1` on HEAD via %D is unreliable; instead we read
959
- // .git/HEAD from disk (sync, ~0ms) and resolve the ref
960
- // * total -> log is unbounded when no limit is given; with a limit
961
- // we still need the count, so it is skipped when the
962
- // caller asks for everything (limit === 0).
963
- const limit = Math.max(0, Math.min(parseInt(url.searchParams.get('limit') || '0', 10) || 0, 2000))
964
- const skip = Math.max(0, parseInt(url.searchParams.get('skip') || '0', 10) || 0)
965
- // total the client already knows from an earlier page (0 = unknown)
966
- const knownTotal = Math.max(0, parseInt(url.searchParams.get('total') || '0', 10) || 0)
967
- // Ordering. `topo` (default) keeps each branch's commits together,
968
- // which is what the grouped view wants. `date` interleaves branches
969
- // strictly by commit date — the flat view asks for this so the
970
- // timeline reads chronologically, at the cost of a branch's commits
971
- // being split across the list.
972
- const order = url.searchParams.get('order') === 'date' ? '--date-order' : '--topo-order'
973
- const args = ['log', '--all', order, '--date=iso-strict',
974
- '--format=%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%d']
975
- if (skip > 0) args.push('--skip=' + skip)
976
- if (limit > 0) args.push('-n', String(limit))
977
- // A limited page needs the true total for the "load more" button;
978
- // the unlimited call already returns every commit (total = length).
979
- const [headName, log] = await Promise.all([
980
- readHeadName(repo),
981
- runGit(repo, args),
982
- ])
983
- const commits = parseLog(log)
984
- // The true total is only needed to label "已显示 50 / 241", so it
985
- // must not delay the first paint. The client can opt out with
986
- // `count=0` and fetch it separately via op=count; everything else
987
- // (paging, which already knows the total) is unaffected.
988
- const wantCount = url.searchParams.get('count') !== '0'
989
- let total
990
- if (skip === 0 && (limit === 0 || commits.length < limit)) {
991
- // full history, or it fit inside the first page
992
- total = commits.length
993
- } else if (knownTotal > 0) {
994
- total = knownTotal
995
- } else if (!wantCount) {
996
- total = 0 // caller will ask via op=count
997
- } else {
998
- // first page of a longer history: ask git once
999
- total = parseInt((await runGit(repo, ['rev-list', '--all', '--count']).catch(() => '0')).trim(), 10) || 0
1000
- }
1001
- sendJson({
1002
- ok: true,
1003
- repo,
1004
- head: headName,
1005
- total,
1006
- commits,
1007
- })
1008
- break
1009
- }
1010
- case 'count': {
1011
- // Total number of commits reachable from any ref, used to label
1012
- // "已显示 50 / 241". Split out of `graph` so the first page does not
1013
- // wait for it (they are two independent spawns otherwise).
1014
- const total = parseInt((await runGit(repo, ['rev-list', '--all', '--count']).catch(() => '0')).trim(), 10) || 0
1015
- sendJson({ ok: true, repo, total })
1016
- break
1017
- }
1018
- case 'head': {
1019
- // Change-detection probe for the 5s auto-refresh tick.
1020
- // Now zero spawns: the ref tips are read off disk (same source as
1021
- // `branches`). This runs every 5 seconds for as long as the plugin
1022
- // is open, so a git launch here used to cost ~870ms of a background
1023
- // process 12 times a minute.
1024
- const disk = readRefsFromDisk(repo, readHeadName(repo))
1025
- if (disk) {
1026
- const lines = disk
1027
- .filter((b) => b.kind === 'local' || b.kind === 'remote')
1028
- .map((b) => `${b.sha} ${b.refname}`)
1029
- .sort()
1030
- sendJson({ ok: true, repo, refs: lines.length, sig: lines.join('|') })
1031
- break
1032
- }
1033
- // fallback: exotic ref layout
1034
- const out = await runGit(repo, [
1035
- 'for-each-ref', '--format=%(objectname) %(refname)',
1036
- 'refs/heads', 'refs/remotes',
1037
- ]).catch(() => '')
1038
- const lines = out.split('\n').map((s) => s.trim()).filter(Boolean)
1039
- // deterministic order so the signature is stable between polls
1040
- lines.sort()
1041
- sendJson({
1042
- ok: true,
1043
- repo,
1044
- refs: lines.length,
1045
- sig: lines.join('|'),
1046
- })
1047
- break
1048
- }
1049
- case 'branches': {
1050
- // Read refs straight off disk (0 spawns). Falls back to
1051
- // `for-each-ref` only if the on-disk layout is unreadable, so an
1052
- // exotic setup still works at the old cost.
1053
- const fromDisk = readRefsFromDisk(repo, readHeadName(repo))
1054
- if (fromDisk) { sendJson({ ok: true, branches: fromDisk }); break }
1055
- // for-each-ref supports %09 (tab) but not %x1f (that is a log/pretty-format feature).
1056
- const out = await runGit(repo, [
1057
- 'for-each-ref',
1058
- '--format=%(refname)%09%(objectname)%09%(HEAD)',
1059
- 'refs/heads', 'refs/remotes', 'refs/tags',
1060
- ])
1061
- const branches = []
1062
- for (const line of out.split('\n')) {
1063
- if (!line) continue
1064
- const [refname, sha, isHead] = line.split('\t')
1065
- branches.push({
1066
- refname,
1067
- kind: refname.startsWith('refs/tags/') ? 'tag' : refname.startsWith('refs/remotes/') ? 'remote' : 'local',
1068
- name: refname.replace(/^refs\/(heads|remotes|tags)\//, ''),
1069
- sha,
1070
- head: isHead === '*',
1071
- })
1072
- }
1073
- sendJson({ ok: true, branches })
1074
- break
1075
- }
1076
- case 'workstatus': {
1077
- // VSCode-style working-tree overview: structured file lists grouped by
1078
- // staged / unstaged / untracked, with per-file +/- counts (numstat).
1079
- // The branch name comes from `status --branch` (first "## <branch>"
1080
- // record) instead of a separate rev-parse call — one less spawn, and
1081
- // this endpoint is polled every 5s by the client.
1082
- const statusOut = await runGit(repo, ['status', '--branch', '--porcelain=v1', '-z']).catch(() => '')
1083
- // The first -z record of `status --branch` is the "## <branch>"
1084
- // header, NUL-terminated like any other record. Parse it out first
1085
- // and hand the remainder to parsePorcelainZ (which would otherwise
1086
- // see "#master..." as a file path and emit a bogus staged entry).
1087
- // "## master...origin/master [ahead 1]" / "## HEAD (no branch)"
1088
- const nulAt = statusOut.indexOf('\0')
1089
- const hasBranchHead = statusOut.startsWith('## ') && nulAt > 0
1090
- const branchLine = hasBranchHead ? statusOut.slice(3, nulAt) : ''
1091
- const filesOut = hasBranchHead ? statusOut.slice(nulAt + 1) : statusOut
1092
- const branch = branchLine
1093
- ? (branchLine.startsWith('No commits yet on ') ? branchLine.slice('No commits yet on '.length)
1094
- : branchLine.startsWith('Initial commit on ') ? branchLine.slice('Initial commit on '.length)
1095
- : branchLine.split('...')[0].split(' ')[0]) || 'HEAD'
1096
- : (await runGit(repo, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => '')).trim() || 'HEAD'
1097
- const statusRecords = parsePorcelainZ(filesOut)
1098
- // Only ask for numstat when there is something to count. On a clean
1099
- // tree (the common polling case) both diffs would spawn a git process
1100
- // each and return nothing, so they are skipped: clean = 1 spawn.
1101
- const needStaged = statusRecords.some((r) => r.x !== ' ' && r.x !== '?' && r.x !== '!')
1102
- const needUnstaged = statusRecords.some((r) => r.y !== ' ' && r.y !== '?' && r.y !== '!')
1103
- const stagedNum = needStaged ? await runGit(repo, ['diff', '--cached', '--numstat', '-z']).catch(() => '') : ''
1104
- const unstagedNum = needUnstaged ? await runGit(repo, ['diff', '--numstat', '-z']).catch(() => '') : ''
1105
- const stagedCounts = parseNumstatZ(stagedNum)
1106
- const unstagedCounts = parseNumstatZ(unstagedNum)
1107
- const staged = []
1108
- const unstaged = []
1109
- const untracked = []
1110
- for (const r of statusRecords) {
1111
- const num = (m) => {
1112
- const n = m.get(r.path)
1113
- return n ? { added: n.added === '-' ? null : parseInt(n.added, 10) || 0, deleted: n.deleted === '-' ? null : parseInt(n.deleted, 10) || 0, binary: n.added === '-' } : { added: 0, deleted: 0, binary: false }
1114
- }
1115
- const conflict = (r.x === 'U' || r.x === 'A' || r.x === 'D') && (r.y === 'U' || r.y === 'A' || r.y === 'D')
1116
- if (r.x === '?' && r.y === '?') {
1117
- untracked.push({ path: r.path })
1118
- continue
1119
- }
1120
- if (conflict) {
1121
- unstaged.push({ path: r.path, oldPath: r.oldPath || '', code: 'U', ...num(unstagedCounts) })
1122
- continue
1123
- }
1124
- if (r.x !== ' ' && r.x !== '?') {
1125
- staged.push({ path: r.path, oldPath: r.oldPath || '', code: r.x, ...num(stagedCounts) })
1126
- }
1127
- if (r.y !== ' ' && r.y !== '?') {
1128
- unstaged.push({ path: r.path, oldPath: r.oldPath || '', code: r.y, ...num(unstagedCounts) })
1129
- }
1130
- }
1131
- sendJson({
1132
- ok: true,
1133
- repo,
1134
- branch,
1135
- staged,
1136
- unstaged,
1137
- untracked,
1138
- counts: { staged: staged.length, unstaged: unstaged.length, untracked: untracked.length },
1139
- })
1140
- break
1141
- }
1142
- case 'workfile': {
1143
- // Per-file diff (or content for untracked files) of the working tree.
1144
- const file = url.searchParams.get('file')
1145
- const staged = url.searchParams.get('staged') || '0' // '0' | '1' | 'untracked'
1146
- if (!file) {
1147
- sendJson({ ok: false, error: 'missing-file' }, 400)
1148
- return
1149
- }
1150
- const full = resolve(repo, file).replace(/\\/g, '/')
1151
- const base = repo.replace(/\\/g, '/')
1152
- if (full !== base && !full.startsWith(base + '/')) {
1153
- sendJson({ ok: false, error: 'path-outside-repo' }, 400)
1154
- return
1155
- }
1156
- if (staged === 'untracked') {
1157
- let st
1158
- try { st = statSync(full) } catch {
1159
- sendJson({ ok: false, error: 'file-not-found', file }, 404)
1160
- return
1161
- }
1162
- if (!st.isFile()) {
1163
- sendJson({ ok: true, isDir: true, content: '' })
1164
- return
1165
- }
1166
- let content = ''
1167
- try { content = readFileSync(full, 'utf8') } catch (err) {
1168
- sendJson({ ok: false, error: String((err && err.message) || err) }, 500)
1169
- return
1170
- }
1171
- if (content.includes('\0')) {
1172
- sendJson({ ok: true, binary: true, content: '' })
1173
- return
1174
- }
1175
- const truncated = content.length > MAX_DIFF_CHARS
1176
- sendJson({ ok: true, content: truncated ? content.slice(0, MAX_DIFF_CHARS) : content, truncated })
1177
- return
1178
- }
1179
- const paths = [file]
1180
- const old = url.searchParams.get('old')
1181
- if (old) paths.push(old)
1182
- const args = (staged === '1'
1183
- ? ['diff', '--cached', '--no-color', '--', ...paths]
1184
- : ['diff', '--no-color', '--', ...paths])
1185
- const out = await runGit(repo, args).catch(() => '')
1186
- const truncated = out.length > MAX_DIFF_CHARS
1187
- sendJson({ ok: true, diff: truncated ? out.slice(0, MAX_DIFF_CHARS) : out, truncated })
1188
- break
1189
- }
1190
- case 'filelog': {
1191
- // Commit history of one file (--follow tracks renames).
1192
- const file = url.searchParams.get('file')
1193
- if (!file) {
1194
- sendJson({ ok: false, error: 'missing-file' }, 400)
1195
- return
1196
- }
1197
- const full = resolve(repo, file).replace(/\\/g, '/')
1198
- const base = repo.replace(/\\/g, '/')
1199
- if (full !== base && !full.startsWith(base + '/')) {
1200
- sendJson({ ok: false, error: 'path-outside-repo' }, 400)
1201
- return
1202
- }
1203
- const log = await runGit(repo, [
1204
- 'log', '--follow', '--topo-order', '--date=iso-strict',
1205
- '--format=%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%d', '--', file,
1206
- ]).catch(() => '')
1207
- sendJson({ ok: true, file, commits: parseLog(log) })
1208
- break
1209
- }
1210
- case 'reveal': {
1211
- // Open the repo folder in the OS file manager (harmless convenience).
1212
- const opener = process.platform === 'win32' ? 'explorer'
1213
- : process.platform === 'darwin' ? 'open'
1214
- : 'xdg-open'
1215
- await runGit(repo, ['rev-parse', '--show-toplevel']).then((root) => {
1216
- return new Promise((resolvePromise) => {
1217
- // GUI file managers may exit non-zero even on success; ignore the code.
1218
- execFile(opener, [root.trim()], { windowsHide: true }, () => resolvePromise())
1219
- })
1220
- })
1221
- sendJson({ ok: true })
1222
- break
1223
- }
1224
- case 'remoteinfo': {
1225
- // Resolve the origin remote to a web base URL (e.g. https://github.com/u/r)
1226
- // so the client can build "/commit/<hash>" links. Returns {web, remote} or
1227
- // {web: null} when no usable remote exists.
1228
- const urlOut = await runGit(repo, ['remote', 'get-url', 'origin']).catch(() => '')
1229
- const raw = urlOut.trim()
1230
- if (!raw) {
1231
- sendJson({ ok: true, web: null, remote: '' })
1232
- break
1233
- }
1234
- let web = raw
1235
- if (/^git@(.+):(.+)$/.test(raw)) {
1236
- // scp-like: git@host:path.git -> https://host/path
1237
- web = raw.replace(/^git@([^:]+):(.*)$/, 'https://$1/$2')
1238
- } else if (/^ssh:\/\//.test(raw)) {
1239
- web = raw.replace(/^ssh:\/\/([^@]+@)?/, 'https://')
1240
- }
1241
- // Drop the .git suffix and any trailing slash.
1242
- web = web.replace(/\.git\/?$/, '').replace(/\/+$/, '')
1243
- // The client feeds this straight into window.open(). A remote such as
1244
- // `javascript:alert(1)//` would otherwise become executable script in
1245
- // the plugin's origin, so only real web URLs may pass. Anything else
1246
- // yields web:null and the UI simply hides the "open GitHub" action.
1247
- if (!isSafeWebUrl(web)) {
1248
- sendJson({ ok: true, web: null, remote: raw, rejected: 'unsafe-url' })
1249
- break
1250
- }
1251
- sendJson({ ok: true, web, remote: raw })
1252
- break
1253
- }
1254
- case 'suggestmsg': {
1255
- // Heuristic commit-message suggestion from the working-tree diff.
1256
- // No external AI call: builds conventional-commit style candidates
1257
- // from file names + add/del counts.
1258
- const stagedNum = await runGit(repo, ['diff', '--cached', '--numstat', '-z']).catch(() => '')
1259
- const unstagedNum = await runGit(repo, ['diff', '--numstat', '-z']).catch(() => '')
1260
- const statusOut = await runGit(repo, ['status', '--porcelain=v1', '-z']).catch(() => '')
1261
- const stagedCounts = parseNumstatZ(stagedNum)
1262
- const unstagedCounts = parseNumstatZ(unstagedNum)
1263
- const untracked = []
1264
- const staged = []
1265
- const unstaged = []
1266
- for (const r of parsePorcelainZ(statusOut)) {
1267
- if (r.x === '?' && r.y === '?') { untracked.push(r.path); continue }
1268
- if (r.x !== ' ' && r.x !== '?') staged.push(r.path)
1269
- if (r.y !== ' ' && r.y !== '?') unstaged.push(r.path)
1270
- }
1271
- const name = (p) => p.split('/').pop()
1272
- const files = [...staged, ...unstaged, ...untracked]
1273
- let ins = 0; let del = 0
1274
- for (const m of [stagedCounts, unstagedCounts]) {
1275
- for (const v of m.values()) {
1276
- ins += v.added === '-' ? 0 : parseInt(v.added, 10) || 0
1277
- del += v.deleted === '-' ? 0 : parseInt(v.deleted, 10) || 0
1278
- }
1279
- }
1280
- const candidates = []
1281
- const uniq = [...new Set(files)]
1282
- if (untracked.length && !staged.length && !unstaged.length) {
1283
- candidates.push(`feat: 新增 ${untracked.map(name).slice(0, 3).join('、')}${untracked.length > 3 ? ' 等' : ''}(${untracked.length} 个文件)`)
1284
- } else if (del >= ins && del > 0) {
1285
- candidates.push(`fix: 移除/精简 ${uniq.map(name).slice(0, 3).join('、')}${uniq.length > 3 ? ' 等' : ''}`)
1286
- } else if (uniq.length) {
1287
- candidates.push(`feat: 更新 ${uniq.map(name).slice(0, 3).join('、')}${uniq.length > 3 ? ' 等' : ''}`)
1288
- }
1289
- if (uniq.length) {
1290
- const names = uniq.map(name).slice(0, 5).join('、')
1291
- candidates.push(`chore: 调整 ${names}${uniq.length > 5 ? ` 等 ${uniq.length} 个文件` : ''}(+${ins} -${del})`)
1292
- }
1293
- if (!uniq.length) candidates.push('chore: 清理/整理')
1294
- sendJson({
1295
- ok: true,
1296
- summary: { staged: staged.length, unstaged: unstaged.length, untracked: untracked.length, insertions: ins, deletions: del },
1297
- files: uniq,
1298
- candidates: candidates.slice(0, 3),
1299
- })
1300
- break
1301
- }
1302
- case 'gitop': {
1303
- // Working-tree write operations (stage/unstage/discard/commit).
1304
- // All file paths are validated to stay inside the repo; the client
1305
- // confirms destructive actions before calling.
1306
- const action = url.searchParams.get('action')
1307
- const files = url.searchParams.getAll('file').filter(Boolean)
1308
- const base = repo.replace(/\\/g, '/')
1309
- const okPath = (p) => {
1310
- const full = resolve(repo, p).replace(/\\/g, '/')
1311
- return full === base || full.startsWith(base + '/')
1312
- }
1313
- if (files.some((p) => !okPath(p))) {
1314
- sendJson({ ok: false, error: 'path-outside-repo' }, 400)
1315
- return
1316
- }
1317
- try {
1318
- switch (action) {
1319
- case 'stage':
1320
- if (!files.length) throw new Error('missing-file')
1321
- await runGit(repo, ['add', '--', ...files])
1322
- break
1323
- case 'stage-all':
1324
- await runGit(repo, ['add', '-A'])
1325
- break
1326
- case 'unstage':
1327
- if (!files.length) throw new Error('missing-file')
1328
- await runGit(repo, ['restore', '--staged', '--', ...files])
1329
- break
1330
- case 'unstage-all':
1331
- // mixed reset: unstage everything, keep working tree
1332
- await runGit(repo, ['reset'])
1333
- break
1334
- case 'discard':
1335
- // Restore file(s) to HEAD: staged + worktree changes are lost.
1336
- if (!files.length) throw new Error('missing-file')
1337
- await runGit(repo, ['restore', '--staged', '--worktree', '--', ...files])
1338
- break
1339
- case 'discard-all':
1340
- // Restore every tracked file to HEAD (untracked files untouched).
1341
- await runGit(repo, ['restore', '--staged', '--worktree', '.'])
1342
- break
1343
- case 'commit': {
1344
- const message = url.searchParams.get('message')
1345
- if (!message || !message.trim()) throw new Error('missing-message')
1346
- if (files.length) {
1347
- // selective commit: stage exactly these files and limit the
1348
- // commit to them (paths are staged so `-- files` works).
1349
- await runGit(repo, ['add', '--', ...files])
1350
- await runGit(repo, ['commit', '-m', message.trim(), '--', ...files])
1351
- } else {
1352
- if (url.searchParams.get('all') === '1') {
1353
- await runGit(repo, ['add', '-A'])
1354
- }
1355
- await runGit(repo, ['commit', '-m', message.trim()])
1356
- }
1357
- break
1358
- }
1359
- case 'ignore': {
1360
- // Add files to .gitignore; tracked files are untracked first
1361
- // (git rm --cached keeps them on disk).
1362
- if (!files.length) throw new Error('missing-file')
1363
- const tracked = []
1364
- for (const f of files) {
1365
- const out = await runGit(repo, ['ls-files', '--error-unmatch', '--', f]).catch(() => '')
1366
- if (out.trim()) tracked.push(f)
1367
- }
1368
- if (tracked.length) {
1369
- await runGit(repo, ['rm', '--cached', '--', ...tracked])
1370
- }
1371
- const gi = join(repo, '.gitignore')
1372
- let lines = ''
1373
- try { lines = readFileSync(gi, 'utf8') } catch { /* no .gitignore yet */ }
1374
- const has = (p) => lines.split(/\r?\n/).some((l) => l.trim() === p)
1375
- const adds = []
1376
- for (const f of files) {
1377
- const norm = f.replace(/\\/g, '/')
1378
- if (!has(norm)) adds.push(norm)
1379
- }
1380
- if (adds.length) {
1381
- const prefix = lines.length && !lines.endsWith('\n') ? '\n' : ''
1382
- appendFileSync(gi, prefix + adds.join('\n') + '\n')
1383
- }
1384
- break
1385
- }
1386
- case 'tag': {
1387
- // Lightweight tag on a commit (harmless; no editor, no push).
1388
- const name = url.searchParams.get('name')
1389
- const hash = url.searchParams.get('hash')
1390
- if (!name || !name.trim()) throw new Error('missing-tag-name')
1391
- if (!hash) throw new Error('missing-hash')
1392
- await runGit(repo, ['tag', name.trim(), hash])
1393
- break
1394
- }
1395
- case 'tag-delete': {
1396
- // Delete a local tag (does not touch remotes).
1397
- const name = url.searchParams.get('name')
1398
- if (!name || !name.trim()) throw new Error('missing-tag-name')
1399
- await runGit(repo, ['tag', '-d', name.trim()])
1400
- break
1401
- }
1402
- case 'tag-rename': {
1403
- // Rename a local tag: create the new name at the same commit,
1404
- // then drop the old one (create-first keeps the tag if delete fails).
1405
- const name = url.searchParams.get('name')
1406
- const newname = url.searchParams.get('newname')
1407
- const hash = url.searchParams.get('hash')
1408
- if (!name || !name.trim()) throw new Error('missing-tag-name')
1409
- if (!newname || !newname.trim()) throw new Error('missing-new-tag-name')
1410
- if (!hash) throw new Error('missing-hash')
1411
- await runGit(repo, ['tag', newname.trim(), hash])
1412
- await runGit(repo, ['tag', '-d', name.trim()])
1413
- break
1414
- }
1415
- case 'branch': {
1416
- // Create a new lightweight branch at the given commit.
1417
- const name = url.searchParams.get('name')
1418
- const hash = url.searchParams.get('hash')
1419
- if (!name || !name.trim()) throw new Error('missing-branch-name')
1420
- if (!hash) throw new Error('missing-hash')
1421
- // Reject names that would escape refs/heads (no slashes-injection guard needed:
1422
- // git itself validates ref names, but keep a sane limit).
1423
- await runGit(repo, ['branch', name.trim(), hash])
1424
- break
1425
- }
1426
- case 'fetch': {
1427
- // Pull remote refs into refs/remotes (no merge). Works with the
1428
- // configured origin, or any remote if none named.
1429
- await runGit(repo, ['fetch', '--all', '--prune'], MAX_GIT_BUFFER, 120000)
1430
- break
1431
- }
1432
- case 'pull': {
1433
- // Pull + merge the current branch from origin (may conflict; the
1434
- // client warns before calling). Returns the pull summary.
1435
- const branch = url.searchParams.get('branch')
1436
- const out = await runGit(repo, ['pull', 'origin', branch || ''], MAX_GIT_BUFFER, 120000)
1437
- sendJson({ ok: true, summary: String(out).trim() })
1438
- return
1439
- }
1440
- case 'push': {
1441
- // Push the current branch to origin. May require credentials;
1442
- // non-fast-forward and auth errors come back as git stderr.
1443
- const branch = url.searchParams.get('branch')
1444
- const out = await runGit(repo, ['push', 'origin', branch || 'HEAD'], MAX_GIT_BUFFER, 120000)
1445
- sendJson({ ok: true, summary: String(out).trim() })
1446
- return
1447
- }
1448
- case 'checkout': {
1449
- // Switch to a local branch (dirty worktree errors come from git).
1450
- const name = url.searchParams.get('name')
1451
- if (!name || !name.trim()) throw new Error('missing-branch-name')
1452
- await runGit(repo, ['checkout', name.trim()])
1453
- break
1454
- }
1455
- case 'delete-branch': {
1456
- // Delete a local branch (-d: refuses unmerged branches).
1457
- const name = url.searchParams.get('name')
1458
- if (!name || !name.trim()) throw new Error('missing-branch-name')
1459
- await runGit(repo, ['branch', '-d', name.trim()])
1460
- break
1461
- }
1462
- default:
1463
- sendJson({ ok: false, error: 'unknown-action' }, 400)
1464
- return
1465
- }
1466
- sendJson({ ok: true })
1467
- } catch (err) {
1468
- sendJson({ ok: false, error: String((err && err.message) || err) }, 500)
1469
- }
1470
- break
1471
- }
1472
- case 'show': {
1473
- const hash = url.searchParams.get('hash')
1474
- if (!hash) {
1475
- sendJson({ ok: false, error: 'missing-hash' }, 400)
1476
- return
1477
- }
1478
- // One spawn instead of two: `show --stat` emits the commit header and
1479
- // the file stat together. The header uses \x1f field separators and is
1480
- // terminated by a marker line that cannot appear in commit metadata,
1481
- // so the stat section can be split off reliably even for root commits
1482
- // (whose %P is empty and therefore contains an empty \x1f field).
1483
- const MARK = '\x1e--STAT--\x1e'
1484
- const out = await runGit(repo, [
1485
- 'show', '--stat', '--no-color',
1486
- `--format=%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%b%n${MARK}`,
1487
- hash,
1488
- ]).catch(() => '')
1489
- const sep = out.indexOf(MARK)
1490
- const metaOut = sep === -1 ? out : out.slice(0, sep)
1491
- const statOut = sep === -1 ? '' : out.slice(sep + MARK.length)
1492
- const parts = metaOut.split('\x1f')
1493
- const sha = parts[0] || hash
1494
- const parents = (parts[1] || '').trim()
1495
- const author = parts[2] || ''
1496
- const email = parts[3] || ''
1497
- const date = parts[4] || ''
1498
- const subject = parts[5] || ''
1499
- const body = parts.slice(6).join('\x1f').replace(/\s+$/, '')
1500
- const stat = statOut.replace(/^\s*\n/, '').replace(/\s+$/, '')
1501
- sendJson({ ok: true, hash: sha, parents: parents ? parents.split(' ') : [], author, email, date, subject, body, stat })
1502
- break
1503
- }
1504
- case 'diff': {
1505
- const hash = url.searchParams.get('hash')
1506
- if (!hash) {
1507
- sendJson({ ok: false, error: 'missing-hash' }, 400)
1508
- return
1509
- }
1510
- // Full patch of one commit (git diff against its first parent; root commits diff against empty tree).
1511
- const out = await runGit(repo, ['show', '--no-color', '--format=', hash]).catch(() => '')
1512
- const truncated = out.length > MAX_DIFF_CHARS
1513
- sendJson({ ok: true, diff: truncated ? out.slice(0, MAX_DIFF_CHARS) : out, truncated })
1514
- break
1515
- }
1516
- case 'filediff': {
1517
- const hash = url.searchParams.get('hash')
1518
- const file = url.searchParams.get('file')
1519
- if (!hash || !file) {
1520
- sendJson({ ok: false, error: 'missing-hash-or-file' }, 400)
1521
- return
1522
- }
1523
- // Diff of a single file inside one commit.
1524
- // One spawn instead of two: `git show <hash> -- <path>` already
1525
- // diffs against the first parent, and for a root commit it diffs
1526
- // against the empty tree — exactly the behaviour the old
1527
- // log-to-get-parents + diff/show pair reproduced by hand.
1528
- const out = await runGit(repo, ['show', '--no-color', '--format=', hash, '--', file]).catch(() => '')
1529
- const truncated = out.length > MAX_DIFF_CHARS
1530
- sendJson({ ok: true, file, diff: truncated ? out.slice(0, MAX_DIFF_CHARS) : out, truncated })
1531
- break
1532
- }
1533
- default:
1534
- sendJson({ ok: false, error: 'unknown-op' }, 400)
1535
- }
1536
- } catch (err) {
1537
- try {
1538
- sendJson({ ok: false, error: String((err && err.message) || err) }, 500)
1539
- } catch {
1540
- /* response already committed */
1541
- }
1542
- }
1543
- },
1544
- })
1545
- }
1
+ // dsh-git-graph — server half
2
+ // Registers HTTP routes under /git-graph:
3
+ // GET /git-graph/api?op=<op>&repo=<path>[&hash=<sha>] -> JSON git data
4
+ // GET /git-graph/index.html -> the visualizer page
5
+ // Commands are executed with execFile (no shell), fixed argument lists only.
6
+ import { execFile, execFileSync } from 'node:child_process'
7
+ import { appendFileSync, copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
8
+ import { basename, dirname, join, resolve } from 'node:path'
9
+ import { tmpdir } from 'node:os'
10
+ import { fileURLToPath } from 'node:url'
11
+ import net from 'node:net'
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url))
14
+ const webRoot = join(here, 'web')
15
+
16
+ const MAX_GIT_BUFFER = 32 * 1024 * 1024
17
+ const MAX_DIFF_CHARS = 240000
18
+
19
+ /* Content-Security-Policy for the graph page and its two static assets.
20
+ * The page used to be a single HTML file with everything inline, which forced
21
+ * 'unsafe-inline' for both scripts and styles. Now that app.js and app.css are
22
+ * separate files, 'self' suffices for each and the page needs no inline
23
+ * allowances at all. Everything else stays locked down: no network origins
24
+ * except this host, no frames, no plugins.
25
+ * `img-src data: blob:` is still required — the UI builds small inline SVG/data
26
+ * images, and the decorative background uses blob-free generated nodes. */
27
+ const PAGE_CSP = [
28
+ "default-src 'none'",
29
+ "script-src 'self'",
30
+ "style-src 'self'",
31
+ 'img-src data: blob:',
32
+ "connect-src 'self'",
33
+ "font-src 'self'",
34
+ "frame-ancestors 'self'",
35
+ ].join('; ')
36
+
37
+ /* ---- persisted UI preferences ------------------------------------------------
38
+ * localStorage is scoped to the page's origin, and the DSH web port changes
39
+ * between launches (e.g. 10426 -> 5828), which silently wiped every saved
40
+ * preference — most visibly the branch filter, which then looked like it had
41
+ * "reset itself". Storing the small, low-risk UI state on disk instead makes it
42
+ * survive port changes and host restarts.
43
+ *
44
+ * Location: alongside the plugin (<plugin>/.state/prefs.json). That directory is
45
+ * gitignored, so a dev checkout stays clean, and the file is per-install so two
46
+ * DSH profiles do not fight over one file.
47
+ * Scope: only UI preferences (branch filter, group visibility, fold state).
48
+ * Never repository content, never credentials. */
49
+ const STATE_DIR = join(here, '.state')
50
+ const PREFS_FILE = join(STATE_DIR, 'prefs.json')
51
+ const PREFS_MAX_BYTES = 256 * 1024
52
+ const PREFS_ALLOWED_KEYS = new Set([
53
+ 'gg2-bfilter', 'gg2-wdshow', 'gg2-wdfolded', 'gg2-wdgroupfolded', 'gg2-brfolded', 'gg2-theme',
54
+ 'gg2-datefrom', 'gg2-dateto', 'gg2-flat', 'gg2-flat-reset',
55
+ ])
56
+
57
+ function readPrefs() {
58
+ try {
59
+ const st = statSync(PREFS_FILE)
60
+ if (st.size > PREFS_MAX_BYTES) return {}
61
+ const raw = readFileSync(PREFS_FILE, 'utf8')
62
+ const obj = JSON.parse(raw)
63
+ return obj && typeof obj === 'object' && !Array.isArray(obj) ? obj : {}
64
+ } catch {
65
+ return {}
66
+ }
67
+ }
68
+
69
+ function writePrefs(patch) {
70
+ const current = readPrefs()
71
+ for (const [k, v] of Object.entries(patch || {})) {
72
+ if (!PREFS_ALLOWED_KEYS.has(k)) continue // allow-list: ignore anything else
73
+ if (v === null || v === undefined) delete current[k]
74
+ else current[k] = v
75
+ }
76
+ mkdirSync(STATE_DIR, { recursive: true })
77
+ writeFileSync(PREFS_FILE, JSON.stringify(current, null, 2), 'utf8')
78
+ return current
79
+ }
80
+
81
+ export const name = 'git-graph'
82
+ export const inject = ['webServer']
83
+
84
+ function runGit(repo, args, maxBuffer = MAX_GIT_BUFFER, timeoutMs = 0) {
85
+ return new Promise((resolve, reject) => {
86
+ const opts = { maxBuffer, windowsHide: true }
87
+ if (timeoutMs > 0) opts.timeout = timeoutMs
88
+ execFile('git', ['-C', repo, ...args], opts, (err, stdout, stderr) => {
89
+ if (err) {
90
+ const detail = (stderr || err.message || '').toString().trim()
91
+ reject(new Error(detail || 'git failed'))
92
+ return
93
+ }
94
+ resolve(stdout.toString())
95
+ })
96
+ })
97
+ }
98
+
99
+ /* Fast local check for a git working tree: does `.git` exist (dir for a normal
100
+ * clone, file for a linked worktree/submodule)? Equivalent to what
101
+ * `git rev-parse --is-inside-work-tree` proves for the cases the repo list
102
+ * cares about, but costs one statSync instead of a ~200ms process launch —
103
+ * and this runs for every configured/known repo on each load. */
104
+ function hasGitEntry(repo) {
105
+ try { return !!statSync(join(repo, '.git')) } catch { return false }
106
+ }
107
+
108
+ async function isGitRepo(repo) {
109
+ try {
110
+ const out = await runGit(repo, ['rev-parse', '--is-inside-work-tree'])
111
+ return out.trim() === 'true'
112
+ } catch {
113
+ return false
114
+ }
115
+ }
116
+
117
+ /* Whether a string is a plain http(s) URL safe to hand to window.open().
118
+ * Anything with another scheme (javascript:, data:, vbscript:, file:) — or a
119
+ * string that is not a URL at all — is rejected. Scheme comparison is done on
120
+ * the parsed URL, so "JAVASCRIPT:" and leading-whitespace variants are caught
121
+ * the same way the browser would interpret them. */
122
+ function isSafeWebUrl(value) {
123
+ try {
124
+ const u = new URL(String(value))
125
+ return (u.protocol === 'http:' || u.protocol === 'https:') && !!u.hostname
126
+ } catch {
127
+ return false
128
+ }
129
+ }
130
+
131
+ /* Resolve the .git directory for a work tree (handles linked worktrees and
132
+ * submodules, where .git is a file containing "gitdir: <path>"). */
133
+ function gitDirOf(repo) {
134
+ let gitDir = join(repo, '.git')
135
+ const st = statSync(gitDir)
136
+ if (st.isFile()) {
137
+ const m = /^gitdir:\s*(.+)$/m.exec(readFileSync(gitDir, 'utf8'))
138
+ if (!m) return null
139
+ const p = m[1].trim()
140
+ gitDir = p.startsWith('/') || /^[A-Za-z]:/.test(p) ? p : resolve(repo, p)
141
+ }
142
+ return gitDir
143
+ }
144
+
145
+ /* Read every local/remote/tag ref straight off disk — zero process spawns.
146
+ * Equivalent to the `for-each-ref` this used to shell out to, which cost a full
147
+ * git launch (~870ms inside the DSH host process on this machine) on every load.
148
+ *
149
+ * Merges the two places refs live:
150
+ * * <gitdir>/packed-refs (packed, sorted; lines "<sha> <refname>", plus
151
+ * "^<sha>" peeled lines for annotated tags which are ignored here)
152
+ * * <gitdir>/refs/**. (loose, overrides packed — a newer loose value wins)
153
+ * Symbolic refs (e.g. refs/remotes/origin/HEAD -> refs/remotes/origin/master)
154
+ * are dereferenced, matching what for-each-ref reports.
155
+ * Returns null when the layout cannot be read, so callers can fall back to git. */
156
+ function readRefsFromDisk(repo, headName) {
157
+ try {
158
+ const gitDir = gitDirOf(repo)
159
+ if (!gitDir) return null
160
+ const refs = new Map() // refname -> sha
161
+ // 1) packed-refs
162
+ try {
163
+ const packed = readFileSync(join(gitDir, 'packed-refs'), 'utf8')
164
+ for (const line of packed.split('\n')) {
165
+ if (!line || line[0] === '#' || line[0] === '^') continue
166
+ const sp = line.indexOf(' ')
167
+ if (sp <= 0) continue
168
+ const sha = line.slice(0, sp).trim()
169
+ const name = line.slice(sp + 1).trim()
170
+ if (sha && name) refs.set(name, sha)
171
+ }
172
+ } catch { /* no packed-refs: all refs are loose */ }
173
+ // 2) loose refs override packed values.
174
+ // Symbolic refs are collected into `pending` and resolved in a SECOND pass,
175
+ // because the target may itself be a loose ref that has not been read yet
176
+ // (e.g. refs/remotes/origin/HEAD -> refs/remotes/origin/master, where the
177
+ // packed value of origin/master is stale and the loose one wins). Resolving
178
+ // them inline would silently report the stale packed sha.
179
+ const pending = [] // [refname, targetRef]
180
+ const walk = (dir, prefix) => {
181
+ let entries
182
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
183
+ for (const e of entries) {
184
+ const full = join(dir, e.name)
185
+ const name = prefix + '/' + e.name
186
+ if (e.isDirectory()) { walk(full, name); continue }
187
+ let content
188
+ try { content = readFileSync(full, 'utf8').trim() } catch { continue }
189
+ if (content.startsWith('ref:')) {
190
+ pending.push([name, content.slice(4).trim()])
191
+ continue
192
+ }
193
+ if (/^[0-9a-f]{40,64}$/i.test(content)) refs.set(name, content)
194
+ }
195
+ }
196
+ for (const base of ['refs/heads', 'refs/remotes', 'refs/tags']) {
197
+ walk(join(gitDir, base), base)
198
+ }
199
+ // second pass: resolve symbolic refs (bounded, so a cycle cannot hang us)
200
+ for (const [name, target] of pending) {
201
+ let cur = target
202
+ for (let hop = 0; hop < 10; hop++) {
203
+ const direct = refs.get(cur)
204
+ if (direct) { refs.set(name, direct); break }
205
+ const next = pending.find((p) => p[0] === cur)
206
+ if (!next) break
207
+ cur = next[1]
208
+ }
209
+ }
210
+ const branches = []
211
+ for (const [refname, sha] of refs) {
212
+ branches.push({
213
+ refname,
214
+ kind: refname.startsWith('refs/tags/') ? 'tag' : refname.startsWith('refs/remotes/') ? 'remote' : 'local',
215
+ name: refname.replace(/^refs\/(heads|remotes|tags)\//, ''),
216
+ sha,
217
+ // the checked-out branch, same meaning as for-each-ref's %(HEAD)
218
+ head: refname === 'refs/heads/' + headName,
219
+ })
220
+ }
221
+ if (!branches.length) return null
222
+ return branches
223
+ } catch {
224
+ return null
225
+ }
226
+ }
227
+
228
+ /* Read the current branch name straight from .git/HEAD (no process spawn).
229
+ * Equivalent to `git symbolic-ref -q --short HEAD`: a detached HEAD yields
230
+ * "HEAD", and any failure falls back to "HEAD" as the old implementation did.
231
+ * Handles linked worktrees / submodules, where .git is a file containing
232
+ * "gitdir: <path>". */
233
+ function readHeadName(repo) {
234
+ try {
235
+ let gitDir = join(repo, '.git')
236
+ let st = statSync(gitDir)
237
+ if (st.isFile()) {
238
+ const m = /^gitdir:\s*(.+)$/m.exec(readFileSync(gitDir, 'utf8'))
239
+ if (!m) return 'HEAD'
240
+ const p = m[1].trim()
241
+ gitDir = p.startsWith('/') || /^[A-Za-z]:/.test(p) ? p : resolve(repo, p)
242
+ }
243
+ const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim()
244
+ if (head.startsWith('ref:')) {
245
+ const ref = head.slice(4).trim() // refs/heads/master
246
+ const short = ref.replace(/^refs\/heads\//, '')
247
+ return short || 'HEAD'
248
+ }
249
+ return 'HEAD' // detached
250
+ } catch {
251
+ return 'HEAD'
252
+ }
253
+ }
254
+
255
+ /* ===================== self-update ===================== */
256
+ // Checks the GitHub repo for newer tags and can replace this plugin's files
257
+ // from a codeload zip. No API key needed: version lookup uses `git ls-remote
258
+ // --tags`, download uses https://codeload.github.com (zip, extracted by
259
+ // bsdtar which ships with Windows 10+ / macOS / most Linux distros).
260
+ const UPDATE_REPO = 'siqyka/dsh-git-graph'
261
+ const UPDATE_GH = `https://github.com/${UPDATE_REPO}`
262
+
263
+ function localVersion() {
264
+ try {
265
+ const pkg = JSON.parse(readFileSync(join(here, 'package.json'), 'utf8'))
266
+ return pkg.version || ''
267
+ } catch { return '' }
268
+ }
269
+
270
+ /* Whether this plugin's own directory lives inside a git work tree (a
271
+ * `link:`/dev checkout mount). Self-update must not overwrite files the user
272
+ * owns through git, so in that mode checkupdate reports managed:'git' and the
273
+ * UI hides the update button in favor of `git pull`. Cached after first call. */
274
+ let gitManagedPromise = null
275
+ function gitManaged() {
276
+ if (!gitManagedPromise) {
277
+ gitManagedPromise = new Promise((res) => {
278
+ execFile('git', ['-C', here, 'rev-parse', '--is-inside-work-tree'], { windowsHide: true }, (err, stdout) => {
279
+ res(!err && String(stdout).trim() === 'true')
280
+ })
281
+ })
282
+ }
283
+ return gitManagedPromise
284
+ }
285
+
286
+ /** Compare dotted versions: 1 if a>b, -1 if a<b, 0 if equal. */
287
+ function cmpVer(a, b) {
288
+ const pa = String(a).replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0)
289
+ const pb = String(b).replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0)
290
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
291
+ const x = pa[i] || 0
292
+ const y = pb[i] || 0
293
+ if (x > y) return 1
294
+ if (x < y) return -1
295
+ }
296
+ return 0
297
+ }
298
+
299
+ /** Extra `git -c` args for network commands. Honors the user's global
300
+ * http(s).proxy / sslBackend / http.version config; if no proxy is set it
301
+ * auto-detects common local proxy ports (Clash/mihomo 7897, Clash 7890,
302
+ * v2ray 10809, socks 1080, etc.) and falls back to OpenSSL + HTTP/1.1
303
+ * (Windows schannel often fails against GitHub on CN networks). */
304
+ const PROXY_PORTS = [7897, 7890, 10809, 1080, 8888, 7891]
305
+ function portOpen(port) {
306
+ return new Promise((resolve) => {
307
+ const sock = new net.Socket()
308
+ sock.setTimeout(400)
309
+ sock.once('connect', () => { sock.destroy(); resolve(true) })
310
+ sock.once('timeout', () => { sock.destroy(); resolve(false) })
311
+ sock.once('error', () => { sock.destroy(); resolve(false) })
312
+ sock.connect(port, '127.0.0.1')
313
+ })
314
+ }
315
+
316
+ /** Resolve a usable proxy for GitHub: global git config wins, then a
317
+ * listening local proxy port. Returns '' when none found. */
318
+ async function resolveProxy() {
319
+ const readGlobal = (k) => {
320
+ try {
321
+ const out = execFileSync('git', ['config', '--global', '--get', k], { encoding: 'utf8', windowsHide: true })
322
+ return out.trim()
323
+ } catch { return '' }
324
+ }
325
+ const fromConfig = readGlobal('http.proxy') || readGlobal('https.proxy')
326
+ if (fromConfig) return fromConfig
327
+ for (const port of PROXY_PORTS) {
328
+ if (await portOpen(port)) return `http://127.0.0.1:${port}`
329
+ }
330
+ return ''
331
+ }
332
+
333
+ /** Extra `git -c` args for network commands: proxy (global config or
334
+ * auto-detected local port) + OpenSSL backend + HTTP/1.1 (Windows
335
+ * schannel often fails against GitHub on CN networks). */
336
+ async function gitNetArgs() {
337
+ const extra = []
338
+ const readGlobal = (k) => {
339
+ try {
340
+ const out = execFileSync('git', ['config', '--global', '--get', k], { encoding: 'utf8', windowsHide: true })
341
+ return out.trim()
342
+ } catch { return '' }
343
+ }
344
+ const proxy = await resolveProxy()
345
+ if (proxy) extra.push('-c', 'http.proxy=' + proxy, '-c', 'https.proxy=' + proxy)
346
+ const backend = readGlobal('http.sslBackend') || 'openssl'
347
+ extra.push('-c', 'http.sslBackend=' + backend)
348
+ const ver = readGlobal('http.version') || 'HTTP/1.1'
349
+ extra.push('-c', 'http.version=' + ver)
350
+ return extra
351
+ }
352
+
353
+ async function latestTag() {
354
+ const out = await runGit(here, [...await gitNetArgs(), 'ls-remote', '--tags', UPDATE_GH], MAX_GIT_BUFFER, 60000)
355
+ const tags = new Set()
356
+ for (const line of String(out).split('\n')) {
357
+ const m = line.match(/refs\/tags\/(v?\d+\.\d+(?:\.\d+)?)(\^\{\})?$/)
358
+ if (m && !m[2]) tags.add(m[1])
359
+ }
360
+ if (!tags.size) return ''
361
+ return [...tags].sort((a, b) => cmpVer(b, a))[0]
362
+ }
363
+
364
+ /** Download + extract + swap this plugin's files with the given tag.
365
+ * Uses `git clone --depth 1 --branch <tag>` so the download rides the same
366
+ * proxy / sslBackend / http.version config as every other git command. */
367
+ async function applyUpdate(tag) {
368
+ if (!/^v?\d+\.\d+(?:\.\d+)?$/.test(tag)) throw new Error('bad-tag')
369
+ const tmp = mkdtempSync(join(tmpdir(), 'ggupd-'))
370
+ const srcDir = join(tmp, 'src')
371
+ try {
372
+ await runGit(here, [...await gitNetArgs(), 'clone', '--depth', '1', '--branch', tag, UPDATE_GH, srcDir], MAX_GIT_BUFFER, 180000)
373
+ if (!statSync(join(srcDir, 'index.js'), { throwIfNoEntry: false })) throw new Error('missing-index')
374
+ if (!statSync(join(srcDir, 'web', 'index.html'), { throwIfNoEntry: false })) throw new Error('missing-web')
375
+ // The page pulls these two in; a release that ships index.html without them
376
+ // would swap in a blank screen, so refuse before touching the install.
377
+ if (!statSync(join(srcDir, 'web', 'app.css'), { throwIfNoEntry: false })) throw new Error('missing-web-css')
378
+ if (!statSync(join(srcDir, 'web', 'app.js'), { throwIfNoEntry: false })) throw new Error('missing-web-js')
379
+
380
+ // backup the current install, then swap (skip the cloned .git dir).
381
+ // The backup lives next to the plugin dir (sibling), never inside it,
382
+ // so it survives clearTree and is easy for users to find.
383
+ const bak = join(dirname(here), basename(here) + '.bak-' + Date.now())
384
+ try {
385
+ copyTree(here, bak)
386
+ clearTree(here)
387
+ copyTree(srcDir, here, true)
388
+ } catch (err) {
389
+ // roll back to the backup
390
+ try { clearTree(here); copyTree(bak, here) } catch { /* best effort */ }
391
+ throw err
392
+ }
393
+ // keep only the newest backup
394
+ pruneBackups()
395
+ } finally {
396
+ // Windows: cloned .git dirs may still be held by AV/antivirus scanners or
397
+ // lingering file handles, so retry; and never let cleanup failure mask a
398
+ // successful update (the swap above already happened).
399
+ try {
400
+ rmSync(tmp, { recursive: true, force: true, maxRetries: 6, retryDelay: 250 })
401
+ } catch {
402
+ scheduleCleanup(tmp)
403
+ }
404
+ }
405
+ }
406
+
407
+ /** Best-effort late cleanup of a temp dir Windows refused to delete yet
408
+ * (handles usually release within a few seconds). Never throws. */
409
+ function scheduleCleanup(dir) {
410
+ let tries = 0
411
+ const attempt = () => {
412
+ tries++
413
+ try {
414
+ rmSync(dir, { recursive: true, force: true })
415
+ } catch {
416
+ if (tries < 12) setTimeout(attempt, 1000)
417
+ }
418
+ }
419
+ setTimeout(attempt, 1500)
420
+ }
421
+
422
+ function copyTree(src, dst, skipDotGit) {
423
+ const cp = (s, d) => {
424
+ const st = statSync(s, { throwIfNoEntry: false })
425
+ if (!st) return
426
+ if (st.isDirectory()) {
427
+ if (skipDotGit && s.endsWith('.git')) return
428
+ mkdirSync(d, { recursive: true })
429
+ for (const name of readdirSync(s)) cp(join(s, name), join(d, name))
430
+ } else if (st.isFile()) {
431
+ copyFileSync(s, d)
432
+ }
433
+ }
434
+ cp(src, dst)
435
+ }
436
+ function clearTree(dir) {
437
+ if (!statSync(dir, { throwIfNoEntry: false })) return
438
+ for (const name of readdirSync(dir)) {
439
+ if (name.startsWith('.bak-')) continue
440
+ rmSync(join(dir, name), { recursive: true, force: true })
441
+ }
442
+ }
443
+ function pruneBackups() {
444
+ const parent = dirname(here)
445
+ const base = basename(here)
446
+ const prefix = base + '.bak-'
447
+ const baks = readdirSync(parent).filter((n) => n.startsWith(prefix)).sort()
448
+ for (const b of baks.slice(0, -1)) rmSync(join(parent, b), { recursive: true, force: true })
449
+ }
450
+
451
+ /** Walk upward from `start` until a directory containing `.git` is found. */
452
+ function findGitRoot(start) {
453
+ let dir = String(start).replace(/\\/g, '/').replace(/\/+$/, '') || '/'
454
+ for (let i = 0; i < 24; i++) {
455
+ try {
456
+ const p = join(dir, '.git')
457
+ const st = statSync(p)
458
+ if (st.isDirectory() || st.isFile()) return dir
459
+ } catch { /* keep walking */ }
460
+ const parent = dirname(dir)
461
+ if (parent === dir) return null
462
+ dir = parent
463
+ }
464
+ return null
465
+ }
466
+
467
+ /** Parse `git log --format=...%x1f...%d` lines into structured commit rows. */
468
+ function parseLog(stdout) {
469
+ const rows = []
470
+ for (const line of stdout.split('\n')) {
471
+ if (!line) continue
472
+ const [hash, parents, author, email, date, subject, refs] = line.split('\x1f')
473
+ rows.push({
474
+ hash,
475
+ parents: parents ? parents.split(' ') : [],
476
+ author,
477
+ email,
478
+ date,
479
+ subject,
480
+ refs: parseRefs(refs),
481
+ })
482
+ }
483
+ return rows
484
+ }
485
+
486
+ /** Parse git refs decoration like " (HEAD -> master, tag: v1, origin/dev)". */
487
+ function parseRefs(raw) {
488
+ const refs = []
489
+ if (!raw) return refs
490
+ const inner = raw.replace(/^\(/, '').replace(/\)$/, '').trim()
491
+ if (!inner) return refs
492
+ for (const part of inner.split(',')) {
493
+ const item = part.trim()
494
+ if (!item) continue
495
+ if (item.startsWith('tag: ')) {
496
+ refs.push({ kind: 'tag', name: item.slice(5).trim() })
497
+ } else if (item.includes(' -> ')) {
498
+ const [, target] = item.split(' -> ')
499
+ refs.push({ kind: 'branch', name: target.trim(), head: item.startsWith('HEAD') })
500
+ } else {
501
+ refs.push({ kind: 'branch', name: item })
502
+ }
503
+ }
504
+ return refs
505
+ }
506
+
507
+ /**
508
+ * Parse `git status --porcelain=v1 -z` output. Returns [{x, y, path, oldPath?}].
509
+ * In -z mode rename/copy entries emit two records: "XY <new path>" then "<old path>".
510
+ */
511
+ function parsePorcelainZ(buf) {
512
+ const records = []
513
+ const parts = buf.split('\0')
514
+ for (let i = 0; i < parts.length; i++) {
515
+ const rec = parts[i]
516
+ if (!rec || rec.length < 3) continue
517
+ const x = rec[0]
518
+ const y = rec[1]
519
+ const path = rec.slice(3)
520
+ if ((x === 'R' || x === 'C') && i + 1 < parts.length && parts[i + 1]) {
521
+ records.push({ x, y, path, oldPath: parts[i + 1] })
522
+ i++
523
+ continue
524
+ }
525
+ records.push({ x, y, path })
526
+ }
527
+ return records
528
+ }
529
+
530
+ /**
531
+ * Parse `git diff --numstat -z` output into a path -> {added, deleted} map.
532
+ * Normal entries are "added\tdeleted\t<path>"; rename entries are
533
+ * "added\tdeleted" + "\0" + "<old>" + "\0" + "<new>" (keyed by the new path);
534
+ * binary entries use "-" for both counts.
535
+ */
536
+ function parseNumstatZ(buf) {
537
+ const map = new Map()
538
+ const parts = buf.split('\0')
539
+ for (let i = 0; i < parts.length; i++) {
540
+ const rec = parts[i]
541
+ if (!rec) continue
542
+ const fields = rec.split('\t')
543
+ if (fields.length < 2) continue
544
+ const added = fields[0]
545
+ const deleted = fields[1]
546
+ let key
547
+ if (fields.length >= 3) {
548
+ key = fields[2]
549
+ } else {
550
+ // rename: "n\tn" then "<old>" then "<new>"
551
+ const old = i + 1 < parts.length ? parts[i + 1] : ''
552
+ const next = i + 2 < parts.length ? parts[i + 2] : ''
553
+ key = next || old
554
+ i += 2
555
+ }
556
+ if (key) map.set(key, { added, deleted })
557
+ }
558
+ return map
559
+ }
560
+
561
+ export function apply(ctx, config = {}) {
562
+ const configured = []
563
+ if (config.repo) configured.push(String(config.repo))
564
+ if (Array.isArray(config.repos)) configured.push(...config.repos.map(String))
565
+ const normalize = (p) => String(p).replace(/[\\/]+$/, '')
566
+ const allowed = [...new Set(configured.map(normalize))].filter(Boolean)
567
+ // Workspace-discovered repos (from the repos op) are accepted for reads too.
568
+ // `recentKeys` holds case-folded keys so Windows paths differing only in case
569
+ // still match (e.g. "C:/Work" vs "c:/work").
570
+ const recentRepos = new Set()
571
+ const recentKeys = new Set()
572
+ const normKey = (p) => {
573
+ const s = String(p).replace(/\\/g, '/').replace(/\/+$/, '')
574
+ return process.platform === 'win32' ? s.toLowerCase() : s
575
+ }
576
+ const repoAllowed = (p) => {
577
+ const k = normKey(p)
578
+ if (allowed.some((a) => normKey(a) === k)) return true
579
+ return recentKeys.has(k)
580
+ }
581
+ const addRecent = (p) => { recentRepos.add(p); recentKeys.add(normKey(p)) }
582
+
583
+ ctx.effect(() => ctx.webServer.register({
584
+ kind: 'prefix',
585
+ path: '/git-graph',
586
+ handler: async (req, res) => {
587
+ /* Baseline hardening headers on every response (JSON and HTML).
588
+ * nosniff stops a mislabeled response being sniffed as script; the page
589
+ * renders inside the plugin iframe, so no-store also keeps a cached
590
+ * snapshot of repo data from lingering in the browser cache. */
591
+ const BASE_HEADERS = {
592
+ 'cache-control': 'no-store',
593
+ 'x-content-type-options': 'nosniff',
594
+ }
595
+ const sendJson = (obj, status = 200) => {
596
+ const body = JSON.stringify(obj)
597
+ res.writeHead(status, {
598
+ ...BASE_HEADERS,
599
+ 'content-type': 'application/json; charset=utf-8',
600
+ })
601
+ res.end(body)
602
+ }
603
+ try {
604
+ const url = new URL(req.url, 'http://localhost')
605
+ const pathname = url.pathname
606
+
607
+ if (pathname === '/git-graph/' || pathname === '/git-graph/index.html') {
608
+ res.writeHead(200, {
609
+ ...BASE_HEADERS,
610
+ 'content-type': 'text/html; charset=utf-8',
611
+ // The page is same-origin with the host GUI and only talks to this
612
+ // API. The stylesheet and the app script are separate files served
613
+ // from here, so 'self' covers both and 'unsafe-inline' is no longer
614
+ // needed for either — it was only required back when the whole app
615
+ // was one inline <script>.
616
+ 'content-security-policy': PAGE_CSP,
617
+ })
618
+ res.end(readFileSync(join(webRoot, 'index.html')))
619
+ return
620
+ }
621
+
622
+ /* Static assets for the page: app.css and app.js. An explicit
623
+ * allow-list rather than a directory server — `web/` holds only these
624
+ * three files today, and an allow-list cannot be tricked into serving
625
+ * something else that ends up in there later. The CSP header goes on
626
+ * these responses too: for a normal subresource the document's policy
627
+ * governs, but a direct navigation to either URL makes it the top-level
628
+ * document, and then the header is the only thing constraining it. */
629
+ if (pathname === '/git-graph/app.css' || pathname === '/git-graph/app.js') {
630
+ const isCss = pathname.endsWith('.css')
631
+ res.writeHead(200, {
632
+ ...BASE_HEADERS,
633
+ 'content-type': isCss ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8',
634
+ 'content-security-policy': PAGE_CSP,
635
+ })
636
+ res.end(readFileSync(join(webRoot, isCss ? 'app.css' : 'app.js')))
637
+ return
638
+ }
639
+
640
+ if (pathname !== '/git-graph/api') {
641
+ sendJson({ ok: false, error: 'not-found' }, 404)
642
+ return
643
+ }
644
+
645
+ /* ---- CSRF guard ---------------------------------------------------
646
+ * Without this, any web page could destroy work by pointing an <img> at
647
+ * `/git-graph/api?op=gitop&action=discard&...`: the browser attaches the
648
+ * request, and GET-only handlers ran it. Three independent checks:
649
+ * 1. writes must be POST (an <img>/<a> can never send POST)
650
+ * 2. Origin, when present, must match this host
651
+ * 3. Referer, when present, must match this host
652
+ * Requests without Origin/Referer are allowed for reads so that
653
+ * non-browser callers (curl, tests, the plugin's own startup) keep
654
+ * working; writes additionally require POST, which browsers cannot
655
+ * forge cross-site without a CORS preflight. */
656
+ const WRITE_OPS = new Set(['gitop', 'autoupdate', 'addrepo', 'prefswrite'])
657
+ const reqOp = url.searchParams.get('op') || 'graph'
658
+ const isWrite = WRITE_OPS.has(reqOp)
659
+ const selfHost = String(req.headers.host || '')
660
+ const sameHost = (value) => {
661
+ if (!value) return null // absent: caller decides
662
+ try {
663
+ const u = new URL(value, 'http://' + selfHost)
664
+ return u.host === selfHost
665
+ } catch { return false }
666
+ }
667
+ const originOk = sameHost(req.headers.origin)
668
+ const refererOk = sameHost(req.headers.referer)
669
+ if (originOk === false || refererOk === false) {
670
+ sendJson({ ok: false, error: 'cross-origin-blocked' }, 403)
671
+ return
672
+ }
673
+ if (isWrite && req.method !== 'POST') {
674
+ sendJson({ ok: false, error: 'method-not-allowed', hint: 'write operations require POST' }, 405)
675
+ return
676
+ }
677
+
678
+ const op = url.searchParams.get('op') || 'graph'
679
+
680
+ if (op === 'prefs' || op === 'prefswrite') {
681
+ // UI preference store (see readPrefs/writePrefs). Repo-independent.
682
+ if (op === 'prefs') {
683
+ sendJson({ ok: true, prefs: readPrefs() })
684
+ return
685
+ }
686
+ // prefswrite: `set` is a JSON object of allow-listed keys to persist.
687
+ const raw = url.searchParams.get('set') || ''
688
+ if (!raw) {
689
+ sendJson({ ok: false, error: 'missing-set' }, 400)
690
+ return
691
+ }
692
+ let patch
693
+ try {
694
+ patch = JSON.parse(raw)
695
+ } catch {
696
+ sendJson({ ok: false, error: 'bad-json' }, 400)
697
+ return
698
+ }
699
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
700
+ sendJson({ ok: false, error: 'bad-shape' }, 400)
701
+ return
702
+ }
703
+ try {
704
+ const prefs = writePrefs(patch)
705
+ sendJson({ ok: true, prefs })
706
+ } catch (err) {
707
+ sendJson({ ok: false, error: String((err && err.message) || err) }, 500)
708
+ }
709
+ return
710
+ }
711
+ if (op === 'repos') {
712
+ // The current session's workspace (ws) is preferred when a git work
713
+ // tree is found at or above it; configured repos follow as fallbacks.
714
+ // `add` re-registers repos the user picked via the folder picker
715
+ // (they are persisted on the client and re-sent on every load,
716
+ // because the in-memory allow-set resets when dsh restarts).
717
+ for (const a of url.searchParams.getAll('add')) {
718
+ const root = findGitRoot(a)
719
+ if (root) addRecent(root)
720
+ }
721
+ // Drop configured paths that are not working git trees, so a stale
722
+ // example config never poisons the repo list (not-a-git-repo).
723
+ // This uses the statSync check: a process launch per configured repo
724
+ // made the endpoint cost ~200ms each for no extra certainty.
725
+ const configuredOk = []
726
+ for (const r of allowed) {
727
+ if (hasGitEntry(r)) configuredOk.push(r)
728
+ }
729
+ // Same for user-added repos: a folder removed from disk must not stay
730
+ // in the switcher forever (it would become the remembered current one).
731
+ // Light existence check (.git entry) + case-folded dedup (recentKeys).
732
+ const recentOk = []
733
+ for (const r of recentRepos) {
734
+ if (hasGitEntry(r) && !recentOk.some((x) => normKey(x) === normKey(r))) recentOk.push(r)
735
+ }
736
+ const ws = normalize(url.searchParams.get('ws') || '')
737
+ let wsRoot = null
738
+ let wsIsGit = false
739
+ if (ws) {
740
+ const root = findGitRoot(ws)
741
+ if (root) {
742
+ wsRoot = root
743
+ wsIsGit = true
744
+ }
745
+ }
746
+ const repos = []
747
+ if (wsRoot) repos.push(wsRoot)
748
+ for (const r of configuredOk) if (!repos.some((x) => normKey(x) === normKey(r))) repos.push(r)
749
+ for (const r of recentOk) if (!repos.some((x) => normKey(x) === normKey(r))) repos.push(r)
750
+ for (const r of repos) addRecent(r)
751
+ let current = null
752
+ if (wsIsGit) current = wsRoot
753
+ else current = repos[0] ?? null // first configured/user repo (ws non-git or absent)
754
+ sendJson({ ok: true, repos, current, ws, wsRoot, wsIsGit })
755
+ return
756
+ }
757
+
758
+ if (op === 'findrepos') {
759
+ // One-click discovery for the empty-state button「在当前对话目录下查找 Git 仓库」:
760
+ // find the git root at/above the workspace; when there is none, shallow-scan
761
+ // subdirectories (depth <= 2, skipping heavy/hidden entries) for git work
762
+ // trees. Read-only: statSync/readdirSync only, no shell. Bounded work:
763
+ // MAX_DIRS enumerated, MAX_RESULTS returned.
764
+ const ws = normalize(url.searchParams.get('ws') || '')
765
+ let wsStat = null
766
+ try { wsStat = statSync(ws) } catch { /* absent */ }
767
+ if (!ws || !wsStat || !wsStat.isDirectory()) {
768
+ sendJson({ ok: false, error: 'bad-ws' }, 400)
769
+ return
770
+ }
771
+ const MAX_DIRS = 300
772
+ const MAX_RESULTS = 20
773
+ const out = []
774
+ const seen = new Set()
775
+ const pushRoot = (dir) => {
776
+ let root = null
777
+ try { root = findGitRoot(dir) } catch { /* ignore */ }
778
+ if (!root) return
779
+ const k = normKey(root)
780
+ if (seen.has(k)) return
781
+ seen.add(k)
782
+ out.push(root)
783
+ }
784
+ const depthOf = (p) => {
785
+ const a = String(ws).replace(/\\/g, '/').split('/')
786
+ const b = String(p).replace(/\\/g, '/').split('/')
787
+ let i = 0
788
+ while (i < a.length && i < b.length && a[i] === b[i]) i++
789
+ return b.length - i
790
+ }
791
+ pushRoot(ws)
792
+ if (out.length === 0) {
793
+ // Shallow scan: enumerate ws and its subdirectories; check every
794
+ // enumerated dir plus one more level for git roots.
795
+ const queue = [ws]
796
+ let dirs = 0
797
+ while (queue.length > 0 && dirs < MAX_DIRS && out.length < MAX_RESULTS) {
798
+ const dir = queue.shift()
799
+ let entries = []
800
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { continue }
801
+ for (const e of entries) {
802
+ if (!e.isDirectory()) continue
803
+ const name = e.name
804
+ if (name === '.git' || name === 'node_modules' || name.startsWith('.') ||
805
+ name === '$RECYCLE.BIN' || name === 'System Volume Information') continue
806
+ const full = join(dir, name)
807
+ if (++dirs > MAX_DIRS) break
808
+ pushRoot(full)
809
+ if (out.length >= MAX_RESULTS) break
810
+ if (depthOf(full) < 2) queue.push(full)
811
+ }
812
+ }
813
+ }
814
+ sendJson({ ok: true, ws, repos: out })
815
+ return
816
+ }
817
+
818
+ if (op === 'listdir') {
819
+ // Directory browser for the "打开文件夹" picker. Read-only; lists only
820
+ // subdirectories (never files) with a per-entry git marker ("git"
821
+ // = the folder contains a .git entry, so it is a repo or a submodule).
822
+ const hasDotGit = (dir) => {
823
+ try { return !!statSync(join(dir, '.git')) } catch { return false }
824
+ }
825
+ const listSubdirs = (dir) => {
826
+ let entries = []
827
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return { dirs: [], truncated: false, readable: false } }
828
+ const names = entries
829
+ .filter((e) => e.isDirectory() && e.name !== '.git')
830
+ .map((e) => e.name)
831
+ .sort((a, b) => a.localeCompare(b))
832
+ const limit = 500
833
+ const dirs = names.slice(0, limit).map((name) => {
834
+ const full = join(dir, name)
835
+ return { name, path: full.replace(/\\/g, '/'), git: hasDotGit(full) }
836
+ })
837
+ return { dirs, truncated: names.length > limit, readable: true }
838
+ }
839
+ let p = String(url.searchParams.get('path') || '').trim().replace(/\\/g, '/')
840
+ if (!p) {
841
+ if (process.platform === 'win32') {
842
+ // No path yet: list the drive letters the picker can start from.
843
+ const drives = []
844
+ for (let c = 65; c <= 90; c++) {
845
+ const d = String.fromCharCode(c) + ':/'
846
+ try { if (statSync(d).isDirectory()) drives.push(d) } catch { /* drive absent */ }
847
+ }
848
+ sendJson({ ok: true, path: '', parent: null, drives, dirs: [], root: null, truncated: false, readable: true })
849
+ return
850
+ }
851
+ p = '/'
852
+ }
853
+ if (/^[A-Za-z]:$/.test(p)) p += '/' // bare drive letter ("C:") -> "C:/"
854
+ let abs
855
+ try { abs = resolve(p) } catch { sendJson({ ok: false, error: 'bad-path', path: p }, 400); return }
856
+ let st
857
+ try { st = statSync(abs) } catch { sendJson({ ok: false, error: 'not-found', path: p }, 404); return }
858
+ if (!st.isDirectory()) { sendJson({ ok: false, error: 'not-a-directory', path: p }, 400); return }
859
+ const norm = abs.replace(/\\/g, '/')
860
+ const par = dirname(norm)
861
+ const { dirs, truncated, readable } = listSubdirs(abs)
862
+ let root = null
863
+ try { root = findGitRoot(abs) } catch { /* ignore */ }
864
+ sendJson({ ok: true, path: norm, parent: par === norm ? null : par, dirs, root, truncated, readable })
865
+ return
866
+ }
867
+
868
+ if (op === 'addrepo') {
869
+ // Register a folder (or any folder inside one) as an openable repo:
870
+ // finds the enclosing git work tree, like the workspace discovery,
871
+ // then verifies it really is a working tree (a bare .git dir alone
872
+ // is not enough — it could be empty/corrupted).
873
+ const p = String(url.searchParams.get('path') || '').trim()
874
+ if (!p) { sendJson({ ok: false, error: 'missing-path' }, 400); return }
875
+ let abs
876
+ try { abs = resolve(p) } catch { sendJson({ ok: false, error: 'bad-path', path: p }, 400); return }
877
+ let st
878
+ try { st = statSync(abs) } catch { sendJson({ ok: false, error: 'not-found', path: p }, 404); return }
879
+ if (!st.isDirectory()) { sendJson({ ok: false, error: 'not-a-directory', path: p }, 400); return }
880
+ const root = findGitRoot(abs)
881
+ if (!root || !(await isGitRepo(root))) { sendJson({ ok: false, error: 'not-a-git-repo', path: p }, 400); return }
882
+ addRecent(root)
883
+ sendJson({ ok: true, repo: root })
884
+ return
885
+ }
886
+
887
+ if (op === 'checkupdate') {
888
+ // Compare the installed version against the latest GitHub tag.
889
+ // Read-only; the client decides whether to offer an update.
890
+ // Even when the network fails we still return the installed
891
+ // version so the UI can always display it. Handled before the repo
892
+ // gate (it needs no repository); when the plugin is mounted from a
893
+ // git checkout the update is always suppressed.
894
+ const current = localVersion()
895
+ const managed = (await gitManaged()) ? 'git' : undefined
896
+ try {
897
+ const latest = await latestTag()
898
+ const hasUpdate = !managed && latest ? cmpVer(latest, current) > 0 : false
899
+ sendJson({ ok: true, current, latest: managed ? '' : latest, hasUpdate, managed })
900
+ } catch (err) {
901
+ sendJson({ ok: true, current, latest: '', hasUpdate: false, network: false, managed, error: String((err && err.message) || err) })
902
+ }
903
+ return
904
+ }
905
+
906
+ if (op === 'autoupdate') {
907
+ // Download the given tag from GitHub and swap this plugin's files.
908
+ // The caller must confirm first; a restart of dsh web is required
909
+ // afterwards (the running process keeps the old code in memory).
910
+ // Also repo-independent: handled before the repo gate.
911
+ if (await gitManaged()) {
912
+ sendJson({ ok: false, error: 'git-managed' }, 409)
913
+ return
914
+ }
915
+ const tag = url.searchParams.get('tag')
916
+ if (!tag) {
917
+ sendJson({ ok: false, error: 'missing-tag' }, 400)
918
+ return
919
+ }
920
+ try {
921
+ await applyUpdate(tag)
922
+ sendJson({ ok: true, restart: true })
923
+ } catch (err) {
924
+ sendJson({ ok: false, error: String((err && err.message) || err) }, 500)
925
+ }
926
+ return
927
+ }
928
+
929
+ let repo = url.searchParams.get('repo') || allowed[0]
930
+ const repoNorm = normalize(repo || '')
931
+ if (!repoNorm || !repoAllowed(repoNorm)) {
932
+ // Do not echo the allow-list: it would hand any unauthenticated caller
933
+ // a map of absolute paths on this machine. The count is enough for the
934
+ // UI to tell "no repos" apart from "wrong repo".
935
+ sendJson({ ok: false, error: 'repo-not-allowed', count: allowed.length + recentRepos.size }, 403)
936
+ return
937
+ }
938
+ repo = repoNorm
939
+
940
+ // Sanity: reject paths that are not existing git work trees.
941
+ // This runs on EVERY api call, so it must not launch a process: a
942
+ // `git rev-parse` here cost ~217ms per request on a machine where
943
+ // process creation is slow — it doubled the cost of every endpoint
944
+ // (show went from one spawn to two, filediff from two to three).
945
+ // A `.git` entry check answers the same question for the cases that
946
+ // matter here, because the path already passed the allow-list check.
947
+ if (!hasGitEntry(repo)) {
948
+ sendJson({ ok: false, error: 'not-a-git-repo', repo }, 400)
949
+ return
950
+ }
951
+
952
+ switch (op) {
953
+ case 'graph': {
954
+ // Spawn count matters a lot here: on Windows each `git` launch costs
955
+ // ~200ms (AV real-time scanning serializes process creation), so the
956
+ // old head + rev-list --count + log triple cost ~670ms. Both extra
957
+ // commands are now folded into the log call:
958
+ // * head -> `log -1` on HEAD via %D is unreliable; instead we read
959
+ // .git/HEAD from disk (sync, ~0ms) and resolve the ref
960
+ // * total -> log is unbounded when no limit is given; with a limit
961
+ // we still need the count, so it is skipped when the
962
+ // caller asks for everything (limit === 0).
963
+ const limit = Math.max(0, Math.min(parseInt(url.searchParams.get('limit') || '0', 10) || 0, 2000))
964
+ const skip = Math.max(0, parseInt(url.searchParams.get('skip') || '0', 10) || 0)
965
+ // total the client already knows from an earlier page (0 = unknown)
966
+ const knownTotal = Math.max(0, parseInt(url.searchParams.get('total') || '0', 10) || 0)
967
+ // Ordering. `topo` (default) keeps each branch's commits together,
968
+ // which is what the grouped view wants. `date` interleaves branches
969
+ // strictly by commit date — the flat view asks for this so the
970
+ // timeline reads chronologically, at the cost of a branch's commits
971
+ // being split across the list.
972
+ const order = url.searchParams.get('order') === 'date' ? '--date-order' : '--topo-order'
973
+ const args = ['log', '--all', order, '--date=iso-strict',
974
+ '--format=%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%d']
975
+ if (skip > 0) args.push('--skip=' + skip)
976
+ if (limit > 0) args.push('-n', String(limit))
977
+ // A limited page needs the true total for the "load more" button;
978
+ // the unlimited call already returns every commit (total = length).
979
+ const [headName, log] = await Promise.all([
980
+ readHeadName(repo),
981
+ runGit(repo, args),
982
+ ])
983
+ const commits = parseLog(log)
984
+ // The true total is only needed to label "已显示 50 / 241", so it
985
+ // must not delay the first paint. The client can opt out with
986
+ // `count=0` and fetch it separately via op=count; everything else
987
+ // (paging, which already knows the total) is unaffected.
988
+ const wantCount = url.searchParams.get('count') !== '0'
989
+ let total
990
+ if (skip === 0 && (limit === 0 || commits.length < limit)) {
991
+ // full history, or it fit inside the first page
992
+ total = commits.length
993
+ } else if (knownTotal > 0) {
994
+ total = knownTotal
995
+ } else if (!wantCount) {
996
+ total = 0 // caller will ask via op=count
997
+ } else {
998
+ // first page of a longer history: ask git once
999
+ total = parseInt((await runGit(repo, ['rev-list', '--all', '--count']).catch(() => '0')).trim(), 10) || 0
1000
+ }
1001
+ sendJson({
1002
+ ok: true,
1003
+ repo,
1004
+ head: headName,
1005
+ total,
1006
+ commits,
1007
+ })
1008
+ break
1009
+ }
1010
+ case 'count': {
1011
+ // Total number of commits reachable from any ref, used to label
1012
+ // "已显示 50 / 241". Split out of `graph` so the first page does not
1013
+ // wait for it (they are two independent spawns otherwise).
1014
+ const total = parseInt((await runGit(repo, ['rev-list', '--all', '--count']).catch(() => '0')).trim(), 10) || 0
1015
+ sendJson({ ok: true, repo, total })
1016
+ break
1017
+ }
1018
+ case 'head': {
1019
+ // Change-detection probe for the 5s auto-refresh tick.
1020
+ // Now zero spawns: the ref tips are read off disk (same source as
1021
+ // `branches`). This runs every 5 seconds for as long as the plugin
1022
+ // is open, so a git launch here used to cost ~870ms of a background
1023
+ // process 12 times a minute.
1024
+ const disk = readRefsFromDisk(repo, readHeadName(repo))
1025
+ if (disk) {
1026
+ const lines = disk
1027
+ .filter((b) => b.kind === 'local' || b.kind === 'remote')
1028
+ .map((b) => `${b.sha} ${b.refname}`)
1029
+ .sort()
1030
+ sendJson({ ok: true, repo, refs: lines.length, sig: lines.join('|') })
1031
+ break
1032
+ }
1033
+ // fallback: exotic ref layout
1034
+ const out = await runGit(repo, [
1035
+ 'for-each-ref', '--format=%(objectname) %(refname)',
1036
+ 'refs/heads', 'refs/remotes',
1037
+ ]).catch(() => '')
1038
+ const lines = out.split('\n').map((s) => s.trim()).filter(Boolean)
1039
+ // deterministic order so the signature is stable between polls
1040
+ lines.sort()
1041
+ sendJson({
1042
+ ok: true,
1043
+ repo,
1044
+ refs: lines.length,
1045
+ sig: lines.join('|'),
1046
+ })
1047
+ break
1048
+ }
1049
+ case 'branches': {
1050
+ // Read refs straight off disk (0 spawns). Falls back to
1051
+ // `for-each-ref` only if the on-disk layout is unreadable, so an
1052
+ // exotic setup still works at the old cost.
1053
+ const fromDisk = readRefsFromDisk(repo, readHeadName(repo))
1054
+ if (fromDisk) { sendJson({ ok: true, branches: fromDisk }); break }
1055
+ // for-each-ref supports %09 (tab) but not %x1f (that is a log/pretty-format feature).
1056
+ const out = await runGit(repo, [
1057
+ 'for-each-ref',
1058
+ '--format=%(refname)%09%(objectname)%09%(HEAD)',
1059
+ 'refs/heads', 'refs/remotes', 'refs/tags',
1060
+ ])
1061
+ const branches = []
1062
+ for (const line of out.split('\n')) {
1063
+ if (!line) continue
1064
+ const [refname, sha, isHead] = line.split('\t')
1065
+ branches.push({
1066
+ refname,
1067
+ kind: refname.startsWith('refs/tags/') ? 'tag' : refname.startsWith('refs/remotes/') ? 'remote' : 'local',
1068
+ name: refname.replace(/^refs\/(heads|remotes|tags)\//, ''),
1069
+ sha,
1070
+ head: isHead === '*',
1071
+ })
1072
+ }
1073
+ sendJson({ ok: true, branches })
1074
+ break
1075
+ }
1076
+ case 'workstatus': {
1077
+ // VSCode-style working-tree overview: structured file lists grouped by
1078
+ // staged / unstaged / untracked, with per-file +/- counts (numstat).
1079
+ // The branch name comes from `status --branch` (first "## <branch>"
1080
+ // record) instead of a separate rev-parse call — one less spawn, and
1081
+ // this endpoint is polled every 5s by the client.
1082
+ const statusOut = await runGit(repo, ['status', '--branch', '--porcelain=v1', '-z']).catch(() => '')
1083
+ // The first -z record of `status --branch` is the "## <branch>"
1084
+ // header, NUL-terminated like any other record. Parse it out first
1085
+ // and hand the remainder to parsePorcelainZ (which would otherwise
1086
+ // see "#master..." as a file path and emit a bogus staged entry).
1087
+ // "## master...origin/master [ahead 1]" / "## HEAD (no branch)"
1088
+ const nulAt = statusOut.indexOf('\0')
1089
+ const hasBranchHead = statusOut.startsWith('## ') && nulAt > 0
1090
+ const branchLine = hasBranchHead ? statusOut.slice(3, nulAt) : ''
1091
+ const filesOut = hasBranchHead ? statusOut.slice(nulAt + 1) : statusOut
1092
+ const branch = branchLine
1093
+ ? (branchLine.startsWith('No commits yet on ') ? branchLine.slice('No commits yet on '.length)
1094
+ : branchLine.startsWith('Initial commit on ') ? branchLine.slice('Initial commit on '.length)
1095
+ : branchLine.split('...')[0].split(' ')[0]) || 'HEAD'
1096
+ : (await runGit(repo, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => '')).trim() || 'HEAD'
1097
+ const statusRecords = parsePorcelainZ(filesOut)
1098
+ // Only ask for numstat when there is something to count. On a clean
1099
+ // tree (the common polling case) both diffs would spawn a git process
1100
+ // each and return nothing, so they are skipped: clean = 1 spawn.
1101
+ const needStaged = statusRecords.some((r) => r.x !== ' ' && r.x !== '?' && r.x !== '!')
1102
+ const needUnstaged = statusRecords.some((r) => r.y !== ' ' && r.y !== '?' && r.y !== '!')
1103
+ const stagedNum = needStaged ? await runGit(repo, ['diff', '--cached', '--numstat', '-z']).catch(() => '') : ''
1104
+ const unstagedNum = needUnstaged ? await runGit(repo, ['diff', '--numstat', '-z']).catch(() => '') : ''
1105
+ const stagedCounts = parseNumstatZ(stagedNum)
1106
+ const unstagedCounts = parseNumstatZ(unstagedNum)
1107
+ const staged = []
1108
+ const unstaged = []
1109
+ const untracked = []
1110
+ for (const r of statusRecords) {
1111
+ const num = (m) => {
1112
+ const n = m.get(r.path)
1113
+ return n ? { added: n.added === '-' ? null : parseInt(n.added, 10) || 0, deleted: n.deleted === '-' ? null : parseInt(n.deleted, 10) || 0, binary: n.added === '-' } : { added: 0, deleted: 0, binary: false }
1114
+ }
1115
+ const conflict = (r.x === 'U' || r.x === 'A' || r.x === 'D') && (r.y === 'U' || r.y === 'A' || r.y === 'D')
1116
+ if (r.x === '?' && r.y === '?') {
1117
+ untracked.push({ path: r.path })
1118
+ continue
1119
+ }
1120
+ if (conflict) {
1121
+ unstaged.push({ path: r.path, oldPath: r.oldPath || '', code: 'U', ...num(unstagedCounts) })
1122
+ continue
1123
+ }
1124
+ if (r.x !== ' ' && r.x !== '?') {
1125
+ staged.push({ path: r.path, oldPath: r.oldPath || '', code: r.x, ...num(stagedCounts) })
1126
+ }
1127
+ if (r.y !== ' ' && r.y !== '?') {
1128
+ unstaged.push({ path: r.path, oldPath: r.oldPath || '', code: r.y, ...num(unstagedCounts) })
1129
+ }
1130
+ }
1131
+ sendJson({
1132
+ ok: true,
1133
+ repo,
1134
+ branch,
1135
+ staged,
1136
+ unstaged,
1137
+ untracked,
1138
+ counts: { staged: staged.length, unstaged: unstaged.length, untracked: untracked.length },
1139
+ })
1140
+ break
1141
+ }
1142
+ case 'workfile': {
1143
+ // Per-file diff (or content for untracked files) of the working tree.
1144
+ const file = url.searchParams.get('file')
1145
+ const staged = url.searchParams.get('staged') || '0' // '0' | '1' | 'untracked'
1146
+ if (!file) {
1147
+ sendJson({ ok: false, error: 'missing-file' }, 400)
1148
+ return
1149
+ }
1150
+ const full = resolve(repo, file).replace(/\\/g, '/')
1151
+ const base = repo.replace(/\\/g, '/')
1152
+ if (full !== base && !full.startsWith(base + '/')) {
1153
+ sendJson({ ok: false, error: 'path-outside-repo' }, 400)
1154
+ return
1155
+ }
1156
+ if (staged === 'untracked') {
1157
+ let st
1158
+ try { st = statSync(full) } catch {
1159
+ sendJson({ ok: false, error: 'file-not-found', file }, 404)
1160
+ return
1161
+ }
1162
+ if (!st.isFile()) {
1163
+ sendJson({ ok: true, isDir: true, content: '' })
1164
+ return
1165
+ }
1166
+ let content = ''
1167
+ try { content = readFileSync(full, 'utf8') } catch (err) {
1168
+ sendJson({ ok: false, error: String((err && err.message) || err) }, 500)
1169
+ return
1170
+ }
1171
+ if (content.includes('\0')) {
1172
+ sendJson({ ok: true, binary: true, content: '' })
1173
+ return
1174
+ }
1175
+ const truncated = content.length > MAX_DIFF_CHARS
1176
+ sendJson({ ok: true, content: truncated ? content.slice(0, MAX_DIFF_CHARS) : content, truncated })
1177
+ return
1178
+ }
1179
+ const paths = [file]
1180
+ const old = url.searchParams.get('old')
1181
+ if (old) paths.push(old)
1182
+ const args = (staged === '1'
1183
+ ? ['diff', '--cached', '--no-color', '--', ...paths]
1184
+ : ['diff', '--no-color', '--', ...paths])
1185
+ const out = await runGit(repo, args).catch(() => '')
1186
+ const truncated = out.length > MAX_DIFF_CHARS
1187
+ sendJson({ ok: true, diff: truncated ? out.slice(0, MAX_DIFF_CHARS) : out, truncated })
1188
+ break
1189
+ }
1190
+ case 'filelog': {
1191
+ // Commit history of one file (--follow tracks renames).
1192
+ const file = url.searchParams.get('file')
1193
+ if (!file) {
1194
+ sendJson({ ok: false, error: 'missing-file' }, 400)
1195
+ return
1196
+ }
1197
+ const full = resolve(repo, file).replace(/\\/g, '/')
1198
+ const base = repo.replace(/\\/g, '/')
1199
+ if (full !== base && !full.startsWith(base + '/')) {
1200
+ sendJson({ ok: false, error: 'path-outside-repo' }, 400)
1201
+ return
1202
+ }
1203
+ const log = await runGit(repo, [
1204
+ 'log', '--follow', '--topo-order', '--date=iso-strict',
1205
+ '--format=%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%d', '--', file,
1206
+ ]).catch(() => '')
1207
+ sendJson({ ok: true, file, commits: parseLog(log) })
1208
+ break
1209
+ }
1210
+ case 'reveal': {
1211
+ // Open the repo folder in the OS file manager (harmless convenience).
1212
+ const opener = process.platform === 'win32' ? 'explorer'
1213
+ : process.platform === 'darwin' ? 'open'
1214
+ : 'xdg-open'
1215
+ await runGit(repo, ['rev-parse', '--show-toplevel']).then((root) => {
1216
+ return new Promise((resolvePromise) => {
1217
+ // GUI file managers may exit non-zero even on success; ignore the code.
1218
+ execFile(opener, [root.trim()], { windowsHide: true }, () => resolvePromise())
1219
+ })
1220
+ })
1221
+ sendJson({ ok: true })
1222
+ break
1223
+ }
1224
+ case 'remoteinfo': {
1225
+ // Resolve the origin remote to a web base URL (e.g. https://github.com/u/r)
1226
+ // so the client can build "/commit/<hash>" links. Returns {web, remote} or
1227
+ // {web: null} when no usable remote exists.
1228
+ const urlOut = await runGit(repo, ['remote', 'get-url', 'origin']).catch(() => '')
1229
+ const raw = urlOut.trim()
1230
+ if (!raw) {
1231
+ sendJson({ ok: true, web: null, remote: '' })
1232
+ break
1233
+ }
1234
+ let web = raw
1235
+ if (/^git@(.+):(.+)$/.test(raw)) {
1236
+ // scp-like: git@host:path.git -> https://host/path
1237
+ web = raw.replace(/^git@([^:]+):(.*)$/, 'https://$1/$2')
1238
+ } else if (/^ssh:\/\//.test(raw)) {
1239
+ web = raw.replace(/^ssh:\/\/([^@]+@)?/, 'https://')
1240
+ }
1241
+ // Drop the .git suffix and any trailing slash.
1242
+ web = web.replace(/\.git\/?$/, '').replace(/\/+$/, '')
1243
+ // The client feeds this straight into window.open(). A remote such as
1244
+ // `javascript:alert(1)//` would otherwise become executable script in
1245
+ // the plugin's origin, so only real web URLs may pass. Anything else
1246
+ // yields web:null and the UI simply hides the "open GitHub" action.
1247
+ if (!isSafeWebUrl(web)) {
1248
+ sendJson({ ok: true, web: null, remote: raw, rejected: 'unsafe-url' })
1249
+ break
1250
+ }
1251
+ sendJson({ ok: true, web, remote: raw })
1252
+ break
1253
+ }
1254
+ case 'suggestmsg': {
1255
+ // Heuristic commit-message suggestion from the working-tree diff.
1256
+ // No external AI call: builds conventional-commit style candidates
1257
+ // from file names + add/del counts.
1258
+ const stagedNum = await runGit(repo, ['diff', '--cached', '--numstat', '-z']).catch(() => '')
1259
+ const unstagedNum = await runGit(repo, ['diff', '--numstat', '-z']).catch(() => '')
1260
+ const statusOut = await runGit(repo, ['status', '--porcelain=v1', '-z']).catch(() => '')
1261
+ const stagedCounts = parseNumstatZ(stagedNum)
1262
+ const unstagedCounts = parseNumstatZ(unstagedNum)
1263
+ const untracked = []
1264
+ const staged = []
1265
+ const unstaged = []
1266
+ for (const r of parsePorcelainZ(statusOut)) {
1267
+ if (r.x === '?' && r.y === '?') { untracked.push(r.path); continue }
1268
+ if (r.x !== ' ' && r.x !== '?') staged.push(r.path)
1269
+ if (r.y !== ' ' && r.y !== '?') unstaged.push(r.path)
1270
+ }
1271
+ const name = (p) => p.split('/').pop()
1272
+ const files = [...staged, ...unstaged, ...untracked]
1273
+ let ins = 0; let del = 0
1274
+ for (const m of [stagedCounts, unstagedCounts]) {
1275
+ for (const v of m.values()) {
1276
+ ins += v.added === '-' ? 0 : parseInt(v.added, 10) || 0
1277
+ del += v.deleted === '-' ? 0 : parseInt(v.deleted, 10) || 0
1278
+ }
1279
+ }
1280
+ const candidates = []
1281
+ const uniq = [...new Set(files)]
1282
+ if (untracked.length && !staged.length && !unstaged.length) {
1283
+ candidates.push(`feat: 新增 ${untracked.map(name).slice(0, 3).join('、')}${untracked.length > 3 ? ' 等' : ''}(${untracked.length} 个文件)`)
1284
+ } else if (del >= ins && del > 0) {
1285
+ candidates.push(`fix: 移除/精简 ${uniq.map(name).slice(0, 3).join('、')}${uniq.length > 3 ? ' 等' : ''}`)
1286
+ } else if (uniq.length) {
1287
+ candidates.push(`feat: 更新 ${uniq.map(name).slice(0, 3).join('、')}${uniq.length > 3 ? ' 等' : ''}`)
1288
+ }
1289
+ if (uniq.length) {
1290
+ const names = uniq.map(name).slice(0, 5).join('、')
1291
+ candidates.push(`chore: 调整 ${names}${uniq.length > 5 ? ` 等 ${uniq.length} 个文件` : ''}(+${ins} -${del})`)
1292
+ }
1293
+ if (!uniq.length) candidates.push('chore: 清理/整理')
1294
+ sendJson({
1295
+ ok: true,
1296
+ summary: { staged: staged.length, unstaged: unstaged.length, untracked: untracked.length, insertions: ins, deletions: del },
1297
+ files: uniq,
1298
+ candidates: candidates.slice(0, 3),
1299
+ })
1300
+ break
1301
+ }
1302
+ case 'gitop': {
1303
+ // Working-tree write operations (stage/unstage/discard/commit).
1304
+ // All file paths are validated to stay inside the repo; the client
1305
+ // confirms destructive actions before calling.
1306
+ const action = url.searchParams.get('action')
1307
+ const files = url.searchParams.getAll('file').filter(Boolean)
1308
+ const base = repo.replace(/\\/g, '/')
1309
+ const okPath = (p) => {
1310
+ const full = resolve(repo, p).replace(/\\/g, '/')
1311
+ return full === base || full.startsWith(base + '/')
1312
+ }
1313
+ if (files.some((p) => !okPath(p))) {
1314
+ sendJson({ ok: false, error: 'path-outside-repo' }, 400)
1315
+ return
1316
+ }
1317
+ try {
1318
+ switch (action) {
1319
+ case 'stage':
1320
+ if (!files.length) throw new Error('missing-file')
1321
+ await runGit(repo, ['add', '--', ...files])
1322
+ break
1323
+ case 'stage-all':
1324
+ await runGit(repo, ['add', '-A'])
1325
+ break
1326
+ case 'unstage':
1327
+ if (!files.length) throw new Error('missing-file')
1328
+ await runGit(repo, ['restore', '--staged', '--', ...files])
1329
+ break
1330
+ case 'unstage-all':
1331
+ // mixed reset: unstage everything, keep working tree
1332
+ await runGit(repo, ['reset'])
1333
+ break
1334
+ case 'discard':
1335
+ // Restore file(s) to HEAD: staged + worktree changes are lost.
1336
+ if (!files.length) throw new Error('missing-file')
1337
+ await runGit(repo, ['restore', '--staged', '--worktree', '--', ...files])
1338
+ break
1339
+ case 'discard-all':
1340
+ // Restore every tracked file to HEAD (untracked files untouched).
1341
+ await runGit(repo, ['restore', '--staged', '--worktree', '.'])
1342
+ break
1343
+ case 'commit': {
1344
+ const message = url.searchParams.get('message')
1345
+ if (!message || !message.trim()) throw new Error('missing-message')
1346
+ if (files.length) {
1347
+ // selective commit: stage exactly these files and limit the
1348
+ // commit to them (paths are staged so `-- files` works).
1349
+ await runGit(repo, ['add', '--', ...files])
1350
+ await runGit(repo, ['commit', '-m', message.trim(), '--', ...files])
1351
+ } else {
1352
+ if (url.searchParams.get('all') === '1') {
1353
+ await runGit(repo, ['add', '-A'])
1354
+ }
1355
+ await runGit(repo, ['commit', '-m', message.trim()])
1356
+ }
1357
+ break
1358
+ }
1359
+ case 'ignore': {
1360
+ // Add files to .gitignore; tracked files are untracked first
1361
+ // (git rm --cached keeps them on disk).
1362
+ if (!files.length) throw new Error('missing-file')
1363
+ const tracked = []
1364
+ for (const f of files) {
1365
+ const out = await runGit(repo, ['ls-files', '--error-unmatch', '--', f]).catch(() => '')
1366
+ if (out.trim()) tracked.push(f)
1367
+ }
1368
+ if (tracked.length) {
1369
+ await runGit(repo, ['rm', '--cached', '--', ...tracked])
1370
+ }
1371
+ const gi = join(repo, '.gitignore')
1372
+ let lines = ''
1373
+ try { lines = readFileSync(gi, 'utf8') } catch { /* no .gitignore yet */ }
1374
+ const has = (p) => lines.split(/\r?\n/).some((l) => l.trim() === p)
1375
+ const adds = []
1376
+ for (const f of files) {
1377
+ const norm = f.replace(/\\/g, '/')
1378
+ if (!has(norm)) adds.push(norm)
1379
+ }
1380
+ if (adds.length) {
1381
+ const prefix = lines.length && !lines.endsWith('\n') ? '\n' : ''
1382
+ appendFileSync(gi, prefix + adds.join('\n') + '\n')
1383
+ }
1384
+ break
1385
+ }
1386
+ case 'tag': {
1387
+ // Lightweight tag on a commit (harmless; no editor, no push).
1388
+ const name = url.searchParams.get('name')
1389
+ const hash = url.searchParams.get('hash')
1390
+ if (!name || !name.trim()) throw new Error('missing-tag-name')
1391
+ if (!hash) throw new Error('missing-hash')
1392
+ await runGit(repo, ['tag', name.trim(), hash])
1393
+ break
1394
+ }
1395
+ case 'tag-delete': {
1396
+ // Delete a local tag (does not touch remotes).
1397
+ const name = url.searchParams.get('name')
1398
+ if (!name || !name.trim()) throw new Error('missing-tag-name')
1399
+ await runGit(repo, ['tag', '-d', name.trim()])
1400
+ break
1401
+ }
1402
+ case 'tag-rename': {
1403
+ // Rename a local tag: create the new name at the same commit,
1404
+ // then drop the old one (create-first keeps the tag if delete fails).
1405
+ const name = url.searchParams.get('name')
1406
+ const newname = url.searchParams.get('newname')
1407
+ const hash = url.searchParams.get('hash')
1408
+ if (!name || !name.trim()) throw new Error('missing-tag-name')
1409
+ if (!newname || !newname.trim()) throw new Error('missing-new-tag-name')
1410
+ if (!hash) throw new Error('missing-hash')
1411
+ await runGit(repo, ['tag', newname.trim(), hash])
1412
+ await runGit(repo, ['tag', '-d', name.trim()])
1413
+ break
1414
+ }
1415
+ case 'branch': {
1416
+ // Create a new lightweight branch at the given commit.
1417
+ const name = url.searchParams.get('name')
1418
+ const hash = url.searchParams.get('hash')
1419
+ if (!name || !name.trim()) throw new Error('missing-branch-name')
1420
+ if (!hash) throw new Error('missing-hash')
1421
+ // Reject names that would escape refs/heads (no slashes-injection guard needed:
1422
+ // git itself validates ref names, but keep a sane limit).
1423
+ await runGit(repo, ['branch', name.trim(), hash])
1424
+ break
1425
+ }
1426
+ case 'fetch': {
1427
+ // Pull remote refs into refs/remotes (no merge). Works with the
1428
+ // configured origin, or any remote if none named.
1429
+ await runGit(repo, ['fetch', '--all', '--prune'], MAX_GIT_BUFFER, 120000)
1430
+ break
1431
+ }
1432
+ case 'pull': {
1433
+ // Pull + merge the current branch from origin (may conflict; the
1434
+ // client warns before calling). Returns the pull summary.
1435
+ const branch = url.searchParams.get('branch')
1436
+ const out = await runGit(repo, ['pull', 'origin', branch || ''], MAX_GIT_BUFFER, 120000)
1437
+ sendJson({ ok: true, summary: String(out).trim() })
1438
+ return
1439
+ }
1440
+ case 'push': {
1441
+ // Push the current branch to origin. May require credentials;
1442
+ // non-fast-forward and auth errors come back as git stderr.
1443
+ const branch = url.searchParams.get('branch')
1444
+ const out = await runGit(repo, ['push', 'origin', branch || 'HEAD'], MAX_GIT_BUFFER, 120000)
1445
+ sendJson({ ok: true, summary: String(out).trim() })
1446
+ return
1447
+ }
1448
+ case 'checkout': {
1449
+ // Switch to a local branch (dirty worktree errors come from git).
1450
+ const name = url.searchParams.get('name')
1451
+ if (!name || !name.trim()) throw new Error('missing-branch-name')
1452
+ await runGit(repo, ['checkout', name.trim()])
1453
+ break
1454
+ }
1455
+ case 'delete-branch': {
1456
+ // Delete a local branch (-d: refuses unmerged branches).
1457
+ const name = url.searchParams.get('name')
1458
+ if (!name || !name.trim()) throw new Error('missing-branch-name')
1459
+ await runGit(repo, ['branch', '-d', name.trim()])
1460
+ break
1461
+ }
1462
+ default:
1463
+ sendJson({ ok: false, error: 'unknown-action' }, 400)
1464
+ return
1465
+ }
1466
+ sendJson({ ok: true })
1467
+ } catch (err) {
1468
+ sendJson({ ok: false, error: String((err && err.message) || err) }, 500)
1469
+ }
1470
+ break
1471
+ }
1472
+ case 'show': {
1473
+ const hash = url.searchParams.get('hash')
1474
+ if (!hash) {
1475
+ sendJson({ ok: false, error: 'missing-hash' }, 400)
1476
+ return
1477
+ }
1478
+ // One spawn instead of two: `show --stat` emits the commit header and
1479
+ // the file stat together. The header uses \x1f field separators and is
1480
+ // terminated by a marker line that cannot appear in commit metadata,
1481
+ // so the stat section can be split off reliably even for root commits
1482
+ // (whose %P is empty and therefore contains an empty \x1f field).
1483
+ const MARK = '\x1e--STAT--\x1e'
1484
+ const out = await runGit(repo, [
1485
+ 'show', '--stat', '--no-color',
1486
+ `--format=%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%b%n${MARK}`,
1487
+ hash,
1488
+ ]).catch(() => '')
1489
+ const sep = out.indexOf(MARK)
1490
+ const metaOut = sep === -1 ? out : out.slice(0, sep)
1491
+ const statOut = sep === -1 ? '' : out.slice(sep + MARK.length)
1492
+ const parts = metaOut.split('\x1f')
1493
+ const sha = parts[0] || hash
1494
+ const parents = (parts[1] || '').trim()
1495
+ const author = parts[2] || ''
1496
+ const email = parts[3] || ''
1497
+ const date = parts[4] || ''
1498
+ const subject = parts[5] || ''
1499
+ const body = parts.slice(6).join('\x1f').replace(/\s+$/, '')
1500
+ const stat = statOut.replace(/^\s*\n/, '').replace(/\s+$/, '')
1501
+ sendJson({ ok: true, hash: sha, parents: parents ? parents.split(' ') : [], author, email, date, subject, body, stat })
1502
+ break
1503
+ }
1504
+ case 'diff': {
1505
+ const hash = url.searchParams.get('hash')
1506
+ if (!hash) {
1507
+ sendJson({ ok: false, error: 'missing-hash' }, 400)
1508
+ return
1509
+ }
1510
+ // Full patch of one commit (git diff against its first parent; root commits diff against empty tree).
1511
+ const out = await runGit(repo, ['show', '--no-color', '--format=', hash]).catch(() => '')
1512
+ const truncated = out.length > MAX_DIFF_CHARS
1513
+ sendJson({ ok: true, diff: truncated ? out.slice(0, MAX_DIFF_CHARS) : out, truncated })
1514
+ break
1515
+ }
1516
+ case 'filediff': {
1517
+ const hash = url.searchParams.get('hash')
1518
+ const file = url.searchParams.get('file')
1519
+ if (!hash || !file) {
1520
+ sendJson({ ok: false, error: 'missing-hash-or-file' }, 400)
1521
+ return
1522
+ }
1523
+ // Diff of a single file inside one commit.
1524
+ // One spawn instead of two: `git show <hash> -- <path>` already
1525
+ // diffs against the first parent, and for a root commit it diffs
1526
+ // against the empty tree — exactly the behaviour the old
1527
+ // log-to-get-parents + diff/show pair reproduced by hand.
1528
+ const out = await runGit(repo, ['show', '--no-color', '--format=', hash, '--', file]).catch(() => '')
1529
+ const truncated = out.length > MAX_DIFF_CHARS
1530
+ sendJson({ ok: true, file, diff: truncated ? out.slice(0, MAX_DIFF_CHARS) : out, truncated })
1531
+ break
1532
+ }
1533
+ default:
1534
+ sendJson({ ok: false, error: 'unknown-op' }, 400)
1535
+ }
1536
+ } catch (err) {
1537
+ try {
1538
+ sendJson({ ok: false, error: String((err && err.message) || err) }, 500)
1539
+ } catch {
1540
+ /* response already committed */
1541
+ }
1542
+ }
1543
+ },
1544
+ }), 'git-graph: /git-graph route')
1545
+ }