@shiqyka/dsh-git-graph 0.0.0-stage → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +139 -2
- package/README_EN.md +140 -0
- package/client.js +64 -0
- package/cordis.patch.yml +21 -0
- package/index.js +1545 -0
- package/package.json +64 -4
- package/web/app.css +1194 -0
- package/web/app.js +4374 -0
- package/web/index.html +258 -0
package/index.js
ADDED
|
@@ -0,0 +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
|
+
}
|