@qzsy/dsh-git-panel 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +73 -2
- package/cordis.patch.yml +5 -0
- package/lib/client.js +2498 -0
- package/lib/index.js +1271 -0
- package/package.json +57 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,1271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of `dsh-git-panel`.
|
|
3
|
+
*
|
|
4
|
+
* The Browser half draws a Git section (changes, commit box, stage/discard,
|
|
5
|
+
* diff preview, commit graph) inside the right Sidebar, but the browser cannot
|
|
6
|
+
* run git and must never be trusted with one. Everything that touches a
|
|
7
|
+
* repository happens here, behind one authenticated `POST /api/git-panel`
|
|
8
|
+
* route below the same `/api` channel as every other Host feature:
|
|
9
|
+
*
|
|
10
|
+
* read status · numstat · graph · diff · commit detail · branches
|
|
11
|
+
* write stage · unstage · discard · commit · amend · undo commit
|
|
12
|
+
* fetch · pull · push · sync · checkout · stash · stash pop
|
|
13
|
+
*
|
|
14
|
+
* The action vocabulary and its payload shape are modelled on the Git module of
|
|
15
|
+
* the local Cursor IDE (its bundled `extensions/git` — 154 contributed
|
|
16
|
+
* commands, the `scm/*` resource groups `merge | index | workingTree |
|
|
17
|
+
* untracked`, and the `git.graph.*` commands that drive the Source Control
|
|
18
|
+
* Graph view). Only the subset a Sidebar panel can honestly drive is here;
|
|
19
|
+
* `lib/README` in the repository package lists what was deliberately left out.
|
|
20
|
+
*
|
|
21
|
+
* Security has one home, here:
|
|
22
|
+
*
|
|
23
|
+
* - The route is registered only through the `connection` service, so the
|
|
24
|
+
* Host/Origin fence and the browser login cookie gate the caller before any
|
|
25
|
+
* repository is read or written.
|
|
26
|
+
* - `cwd` must be an absolute directory that resolves inside a Git work tree.
|
|
27
|
+
* - Every path a write action names is resolved against the repository root
|
|
28
|
+
* and must stay inside it; `..`, absolute paths and symlink escapes are
|
|
29
|
+
* refused before git runs.
|
|
30
|
+
* - Commands are spawned with an argument vector (never a shell) and a
|
|
31
|
+
* scrubbed environment: no terminal prompt, no pager, C locale.
|
|
32
|
+
*
|
|
33
|
+
* @module dsh-git-panel
|
|
34
|
+
*/
|
|
35
|
+
import { spawn } from 'node:child_process'
|
|
36
|
+
import { readFile, rm, stat } from 'node:fs/promises'
|
|
37
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
38
|
+
|
|
39
|
+
/** Stable Loader identity. */
|
|
40
|
+
export const name = 'git-panel'
|
|
41
|
+
|
|
42
|
+
/** Exact route below `/api`; the browser half requests the same path. */
|
|
43
|
+
export const ROUTE = '/api/git-panel'
|
|
44
|
+
|
|
45
|
+
/** Milliseconds one read command may run before the request gives up. */
|
|
46
|
+
const READ_TIMEOUT_MS = 15000
|
|
47
|
+
/** Writes and network commands are slower; the UI shows progress for them. */
|
|
48
|
+
const WRITE_TIMEOUT_MS = 120000
|
|
49
|
+
/** Bytes kept per command; a bigger diff listing is refused rather than truncated silently. */
|
|
50
|
+
const MAX_BUFFER = 16 * 1024 * 1024
|
|
51
|
+
/** A single file preview beyond this stays a "binary or too large" stub in the diff tab. */
|
|
52
|
+
const MAX_PREVIEW_BYTES = 1024 * 1024
|
|
53
|
+
/** Longest absolute `cwd` accepted from the browser. */
|
|
54
|
+
const MAX_PATH_LENGTH = 4096
|
|
55
|
+
/** Default commit graph depth. */
|
|
56
|
+
const DEFAULT_GRAPH_LIMIT = 60
|
|
57
|
+
/** Hard ceiling for the graph depth. */
|
|
58
|
+
const MAX_GRAPH_LIMIT = 400
|
|
59
|
+
|
|
60
|
+
/** Value reads stay fresh for the same caller inside this window (one render pass). */
|
|
61
|
+
const STATUS_TTL_MS = 1200
|
|
62
|
+
|
|
63
|
+
/** `path -> { at, value }` for `status` only: the panel polls it. */
|
|
64
|
+
const statusCache = new Map()
|
|
65
|
+
|
|
66
|
+
/** A refusal the browser can translate by `code`. */
|
|
67
|
+
class PanelError extends Error {
|
|
68
|
+
constructor(code, message, details = undefined) {
|
|
69
|
+
super(message)
|
|
70
|
+
this.name = 'PanelError'
|
|
71
|
+
this.code = code
|
|
72
|
+
this.details = details
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* ------------------------------------------------------------------ */
|
|
77
|
+
/* Running git */
|
|
78
|
+
/* ------------------------------------------------------------------ */
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One settled git result. A nonzero exit is a result, not an exception: the
|
|
82
|
+
* panel shows stderr (a rejected push is an answer the user must read).
|
|
83
|
+
*/
|
|
84
|
+
function settled(exitCode, stdout, stderr, capped) {
|
|
85
|
+
return { exitCode, stdout, stderr, capped }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Spawn `git -C <cwd> <args>` with a scrubbed environment.
|
|
90
|
+
* @param {string} cwd - repository working directory.
|
|
91
|
+
* @param {string[]} args - git arguments; never shell-interpreted.
|
|
92
|
+
* @param {object} [options] - stdin text, timeout, cancellation, read-only hint.
|
|
93
|
+
* @returns {Promise<object>} settled result.
|
|
94
|
+
*/
|
|
95
|
+
function runGit(cwd, args, options = {}) {
|
|
96
|
+
const {
|
|
97
|
+
stdin,
|
|
98
|
+
signal,
|
|
99
|
+
timeoutMs = READ_TIMEOUT_MS,
|
|
100
|
+
readOnly = true,
|
|
101
|
+
maxBuffer = MAX_BUFFER,
|
|
102
|
+
} = options
|
|
103
|
+
|
|
104
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
105
|
+
const env = {
|
|
106
|
+
...process.env,
|
|
107
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
108
|
+
GIT_PAGER: 'cat',
|
|
109
|
+
PAGER: 'cat',
|
|
110
|
+
LC_ALL: 'C',
|
|
111
|
+
LANG: 'C',
|
|
112
|
+
}
|
|
113
|
+
// Optional locks stay off for reads: a panel poll must never touch the
|
|
114
|
+
// index of a repository the user is not looking at. Writes need their own
|
|
115
|
+
// locks, so the hint is dropped there.
|
|
116
|
+
if (readOnly) env.GIT_OPTIONAL_LOCKS = '0'
|
|
117
|
+
|
|
118
|
+
let child
|
|
119
|
+
try {
|
|
120
|
+
child = spawn('git', ['-C', cwd, ...args], {
|
|
121
|
+
cwd,
|
|
122
|
+
env,
|
|
123
|
+
stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
124
|
+
windowsHide: true,
|
|
125
|
+
})
|
|
126
|
+
} catch (error) {
|
|
127
|
+
rejectPromise(error instanceof Error ? error : new Error(String(error)))
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let stdout = ''
|
|
132
|
+
let stderr = ''
|
|
133
|
+
let capped = false
|
|
134
|
+
let timer
|
|
135
|
+
const stop = () => {
|
|
136
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
137
|
+
timer = undefined
|
|
138
|
+
}
|
|
139
|
+
const onAbort = () => {
|
|
140
|
+
stop()
|
|
141
|
+
child.kill('SIGKILL')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (signal !== undefined) {
|
|
145
|
+
if (signal.aborted === true) {
|
|
146
|
+
onAbort()
|
|
147
|
+
rejectPromise(new PanelError('cancelled', 'the request was cancelled'))
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
timer = setTimeout(() => {
|
|
154
|
+
capped = true
|
|
155
|
+
child.kill('SIGKILL')
|
|
156
|
+
}, timeoutMs)
|
|
157
|
+
|
|
158
|
+
child.stdout?.setEncoding('utf8')
|
|
159
|
+
child.stderr?.setEncoding('utf8')
|
|
160
|
+
child.stdout?.on('data', (chunk) => {
|
|
161
|
+
if (stdout.length > maxBuffer) return
|
|
162
|
+
stdout += chunk
|
|
163
|
+
if (stdout.length > maxBuffer) {
|
|
164
|
+
capped = true
|
|
165
|
+
child.kill('SIGKILL')
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
child.stderr?.on('data', (chunk) => {
|
|
169
|
+
if (stderr.length > 1024 * 1024) return
|
|
170
|
+
stderr += chunk
|
|
171
|
+
})
|
|
172
|
+
child.on('error', (error) => {
|
|
173
|
+
stop()
|
|
174
|
+
signal?.removeEventListener?.('abort', onAbort)
|
|
175
|
+
rejectPromise(new PanelError('spawn-failed', error.message))
|
|
176
|
+
})
|
|
177
|
+
child.on('close', (code) => {
|
|
178
|
+
stop()
|
|
179
|
+
signal?.removeEventListener?.('abort', onAbort)
|
|
180
|
+
if (signal?.aborted === true) {
|
|
181
|
+
rejectPromise(new PanelError('cancelled', 'the request was cancelled'))
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
if (capped === true && code !== 0 && stdout === '') {
|
|
185
|
+
rejectPromise(new PanelError('timeout', `git ${args[0] ?? ''} did not finish in ${timeoutMs} ms`))
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
resolvePromise(settled(capped ? 124 : code, stdout, stderr, capped))
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
if (stdin !== undefined && child.stdin !== undefined) {
|
|
192
|
+
child.stdin.end(stdin)
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Run a command and throw when git fails, attaching stderr to the refusal.
|
|
199
|
+
* @param {string} cwd - working directory.
|
|
200
|
+
* @param {string[]} args - git arguments.
|
|
201
|
+
* @param {object} [options] - see {@link runGit}.
|
|
202
|
+
* @returns {Promise<string>} stdout on success.
|
|
203
|
+
*/
|
|
204
|
+
async function mustGit(cwd, args, options = {}) {
|
|
205
|
+
const result = await runGit(cwd, args, options)
|
|
206
|
+
if (result.exitCode !== 0) {
|
|
207
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `git ${args.join(' ')} failed`
|
|
208
|
+
throw new PanelError('git-failed', detail, { args, exitCode: result.exitCode })
|
|
209
|
+
}
|
|
210
|
+
return result.stdout
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* ------------------------------------------------------------------ */
|
|
214
|
+
/* Repository resolution */
|
|
215
|
+
/* ------------------------------------------------------------------ */
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Resolve the repository a working directory belongs to.
|
|
219
|
+
* `repo: false` is a normal answer ("this directory is not a Git work tree"),
|
|
220
|
+
* never a failure.
|
|
221
|
+
* @param {string} cwd - absolute Session working directory.
|
|
222
|
+
* @param {AbortSignal} [signal] - request signal.
|
|
223
|
+
* @returns {Promise<object>} `{ repo: false }` or the repository facts.
|
|
224
|
+
*/
|
|
225
|
+
async function locateRepository(cwd, signal) {
|
|
226
|
+
const inside = await runGit(cwd, ['rev-parse', '--is-inside-work-tree'], { signal })
|
|
227
|
+
if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true') return { repo: false }
|
|
228
|
+
|
|
229
|
+
const top = await runGit(cwd, ['rev-parse', '--show-toplevel'], { signal })
|
|
230
|
+
if (top.exitCode !== 0) return { repo: false }
|
|
231
|
+
const root = top.stdout.trim()
|
|
232
|
+
if (root === '') return { repo: false }
|
|
233
|
+
|
|
234
|
+
const gitDir = await runGit(cwd, ['rev-parse', '--absolute-git-dir'], { signal })
|
|
235
|
+
return {
|
|
236
|
+
repo: true,
|
|
237
|
+
root,
|
|
238
|
+
gitDir: gitDir.exitCode === 0 ? gitDir.stdout.trim() : `${root}${sep}.git`,
|
|
239
|
+
name: root.split(/[\\/]/).filter(Boolean).pop() ?? root,
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Check that one caller-supplied path stays inside the repository root.
|
|
245
|
+
* Refuses absolute paths, `..` segments and anything that escapes after
|
|
246
|
+
* resolution, so a write can never reach outside the work tree it named.
|
|
247
|
+
* @param {string} root - repository top level.
|
|
248
|
+
* @param {string} path - repository-relative path from the request.
|
|
249
|
+
* @returns {string} the normalized relative path.
|
|
250
|
+
*/
|
|
251
|
+
function containedPath(root, path) {
|
|
252
|
+
if (typeof path !== 'string' || path === '' || path.length > MAX_PATH_LENGTH) {
|
|
253
|
+
throw new PanelError('bad-path', 'a path must be a non-empty string')
|
|
254
|
+
}
|
|
255
|
+
if (isAbsolute(path)) throw new PanelError('bad-path', 'absolute paths are refused')
|
|
256
|
+
const normalized = path.replace(/\\/g, '/')
|
|
257
|
+
const absolute = resolve(root, normalized)
|
|
258
|
+
const rel = relative(root, absolute)
|
|
259
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
|
|
260
|
+
throw new PanelError('bad-path', `${path} escapes the repository`)
|
|
261
|
+
}
|
|
262
|
+
return rel.split(sep).join('/')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Read and validate the caller's `cwd`.
|
|
267
|
+
* @param {unknown} value - request field.
|
|
268
|
+
* @returns {string} absolute directory path.
|
|
269
|
+
*/
|
|
270
|
+
function readCwd(value) {
|
|
271
|
+
if (typeof value !== 'string' || value === '' || value.length > MAX_PATH_LENGTH || !isAbsolute(value)) {
|
|
272
|
+
throw new PanelError('bad-cwd', 'cwd must be an absolute directory path')
|
|
273
|
+
}
|
|
274
|
+
return value
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/* ------------------------------------------------------------------ */
|
|
278
|
+
/* Status */
|
|
279
|
+
/* ------------------------------------------------------------------ */
|
|
280
|
+
|
|
281
|
+
/** Long status letter for one porcelain code point. */
|
|
282
|
+
const LETTERS = {
|
|
283
|
+
M: 'M',
|
|
284
|
+
A: 'A',
|
|
285
|
+
D: 'D',
|
|
286
|
+
R: 'R',
|
|
287
|
+
C: 'C',
|
|
288
|
+
U: 'U',
|
|
289
|
+
T: 'T',
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Turn one porcelain XY pair into the two letters the panel shows.
|
|
294
|
+
* @param {string} code - two-character `XY` field.
|
|
295
|
+
* @returns {{ index: string, worktree: string }} letters, empty for unchanged.
|
|
296
|
+
*/
|
|
297
|
+
function lettersOf(code) {
|
|
298
|
+
const x = code[0] ?? '.'
|
|
299
|
+
const y = code[1] ?? '.'
|
|
300
|
+
return { index: LETTERS[x] ?? '', worktree: LETTERS[y] ?? '' }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Parse `git status --porcelain=v2 --branch -z --untracked-files=all`.
|
|
305
|
+
*
|
|
306
|
+
* The NUL framing is what keeps unusual file names intact: a record's own path
|
|
307
|
+
* is its last space-separated field (so spaces survive), and a rename's
|
|
308
|
+
* original path is the following NUL-terminated field.
|
|
309
|
+
* @param {string} raw - stdout.
|
|
310
|
+
* @returns {object} branch facts and the four entry lists.
|
|
311
|
+
*/
|
|
312
|
+
export function parseStatus(raw) {
|
|
313
|
+
const fields = raw.split('\0')
|
|
314
|
+
const branch = { head: null, upstream: null, ahead: 0, behind: 0, oid: null }
|
|
315
|
+
/** @type {object[]} */
|
|
316
|
+
const staged = []
|
|
317
|
+
/** @type {object[]} */
|
|
318
|
+
const changes = []
|
|
319
|
+
/** @type {object[]} */
|
|
320
|
+
const untracked = []
|
|
321
|
+
/** @type {object[]} */
|
|
322
|
+
const conflicts = []
|
|
323
|
+
|
|
324
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
325
|
+
const record = fields[index]
|
|
326
|
+
if (record === '') continue
|
|
327
|
+
|
|
328
|
+
if (record.startsWith('# ')) {
|
|
329
|
+
const [key, ...rest] = record.slice(2).split(' ')
|
|
330
|
+
const value = rest.join(' ')
|
|
331
|
+
if (key === 'branch.oid') branch.oid = value
|
|
332
|
+
else if (key === 'branch.head') branch.head = value === '(detached)' ? null : value
|
|
333
|
+
else if (key === 'branch.upstream') branch.upstream = value
|
|
334
|
+
else if (key === 'branch.ab') {
|
|
335
|
+
const [ahead, behind] = value.split(' ')
|
|
336
|
+
branch.ahead = Number.parseInt(String(ahead).replace('+', ''), 10) || 0
|
|
337
|
+
branch.behind = Math.abs(Number.parseInt(String(behind), 10) || 0)
|
|
338
|
+
}
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const kind = record[0]
|
|
343
|
+
if (kind === '1' || kind === '2') {
|
|
344
|
+
const parts = record.split(' ')
|
|
345
|
+
// 1 XY sub mH mI mW hH hI path
|
|
346
|
+
// 2 XY sub mH mI mW hH hI Xscore path (+ original path as the next field)
|
|
347
|
+
const pathStart = kind === '2' ? 9 : 8
|
|
348
|
+
const path = parts.slice(pathStart).join(' ')
|
|
349
|
+
const code = parts[1] ?? '..'
|
|
350
|
+
const letters = lettersOf(code)
|
|
351
|
+
const originalPath = kind === '2' ? fields[index + 1] : undefined
|
|
352
|
+
if (kind === '2') index += 1
|
|
353
|
+
const entry = {
|
|
354
|
+
path,
|
|
355
|
+
code,
|
|
356
|
+
index: letters.index,
|
|
357
|
+
worktree: letters.worktree,
|
|
358
|
+
originalPath: originalPath === '' ? undefined : originalPath,
|
|
359
|
+
}
|
|
360
|
+
if (letters.index !== '') staged.push({ ...entry, letter: letters.index })
|
|
361
|
+
if (letters.worktree !== '') changes.push({ ...entry, letter: letters.worktree })
|
|
362
|
+
continue
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (kind === 'u') {
|
|
366
|
+
const parts = record.split(' ')
|
|
367
|
+
// u XY sub m1 m2 m3 mW h1 h2 h3 path
|
|
368
|
+
const path = parts.slice(10).join(' ')
|
|
369
|
+
const letters = lettersOf(parts[1] ?? '..')
|
|
370
|
+
conflicts.push({
|
|
371
|
+
path,
|
|
372
|
+
code: parts[1] ?? '..',
|
|
373
|
+
letter: letters.worktree || letters.index || 'U',
|
|
374
|
+
})
|
|
375
|
+
continue
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (kind === '?') {
|
|
379
|
+
untracked.push({ path: record.slice(2), letter: 'U', code: '??' })
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return { branch, staged, changes, untracked, conflicts }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Parse `git diff --numstat -z` into `path -> { additions, deletions, binary }`.
|
|
388
|
+
* Rename records put the two paths in their own NUL fields, so they are read
|
|
389
|
+
* from the same field stream the status uses.
|
|
390
|
+
* @param {string} raw - stdout.
|
|
391
|
+
* @returns {Map<string, object>} counts by path.
|
|
392
|
+
*/
|
|
393
|
+
export function parseNumstat(raw) {
|
|
394
|
+
const counts = new Map()
|
|
395
|
+
const fields = raw.split('\0')
|
|
396
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
397
|
+
const field = fields[index]
|
|
398
|
+
if (field === '') continue
|
|
399
|
+
const parts = field.split('\t')
|
|
400
|
+
if (parts.length < 3) continue
|
|
401
|
+
const [additions, deletions] = parts
|
|
402
|
+
let path = parts.slice(2).join('\t')
|
|
403
|
+
if (path === '') {
|
|
404
|
+
// Rename: the empty path slot is followed by `<old>\0<new>\0`.
|
|
405
|
+
path = fields[index + 2] ?? fields[index + 1] ?? ''
|
|
406
|
+
index += 2
|
|
407
|
+
}
|
|
408
|
+
if (path === '') continue
|
|
409
|
+
const binary = additions === '-' || deletions === '-'
|
|
410
|
+
counts.set(path, {
|
|
411
|
+
additions: binary ? 0 : Number.parseInt(additions, 10) || 0,
|
|
412
|
+
deletions: binary ? 0 : Number.parseInt(deletions, 10) || 0,
|
|
413
|
+
binary,
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
return counts
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Build the complete status payload for one working directory.
|
|
421
|
+
* @param {string} cwd - absolute directory.
|
|
422
|
+
* @param {AbortSignal} [signal] - request signal.
|
|
423
|
+
* @param {boolean} [force] - ignore the short read cache.
|
|
424
|
+
* @returns {Promise<object>} the payload the panel renders.
|
|
425
|
+
*/
|
|
426
|
+
export async function readStatus(cwd, signal, force = false) {
|
|
427
|
+
const cached = statusCache.get(cwd)
|
|
428
|
+
if (force !== true && cached !== undefined && Date.now() - cached.at < STATUS_TTL_MS) return cached.value
|
|
429
|
+
|
|
430
|
+
const repository = await locateRepository(cwd, signal)
|
|
431
|
+
if (repository.repo !== true) return rememberStatus(cwd, { repo: false, cwd })
|
|
432
|
+
|
|
433
|
+
const [status, stagedCounts, worktreeCounts, head, upstreamExists, mergeHead] = await Promise.all([
|
|
434
|
+
runGit(cwd, ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], { signal }),
|
|
435
|
+
runGit(cwd, ['diff', '--cached', '--numstat', '-z'], { signal }),
|
|
436
|
+
runGit(cwd, ['diff', '--numstat', '-z'], { signal }),
|
|
437
|
+
runGit(cwd, ['rev-parse', '--short', 'HEAD'], { signal }),
|
|
438
|
+
runGit(cwd, ['rev-parse', '--verify', '--quiet', 'HEAD'], { signal }),
|
|
439
|
+
runGit(cwd, ['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'], { signal }),
|
|
440
|
+
])
|
|
441
|
+
if (status.exitCode !== 0) {
|
|
442
|
+
throw new PanelError('git-failed', status.stderr.trim() || 'git status failed')
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const parsed = parseStatus(status.stdout)
|
|
446
|
+
const staged = mergeCounts(parsed.staged, parseNumstat(stagedCounts.stdout))
|
|
447
|
+
const changes = mergeCounts(parsed.changes, parseNumstat(worktreeCounts.stdout))
|
|
448
|
+
|
|
449
|
+
const payload = {
|
|
450
|
+
repo: true,
|
|
451
|
+
cwd,
|
|
452
|
+
root: repository.root,
|
|
453
|
+
name: repository.name,
|
|
454
|
+
branch: parsed.branch.head,
|
|
455
|
+
detached: parsed.branch.head === null,
|
|
456
|
+
oid: head.exitCode === 0 ? head.stdout.trim() : null,
|
|
457
|
+
unborn: upstreamExists.exitCode !== 0 && parsed.branch.oid === '',
|
|
458
|
+
upstream: parsed.branch.upstream,
|
|
459
|
+
ahead: parsed.branch.ahead,
|
|
460
|
+
behind: parsed.branch.behind,
|
|
461
|
+
staged,
|
|
462
|
+
changes,
|
|
463
|
+
untracked: parsed.untracked,
|
|
464
|
+
conflicts: parsed.conflicts,
|
|
465
|
+
merging: mergeHead.exitCode === 0,
|
|
466
|
+
counts: {
|
|
467
|
+
staged: staged.length,
|
|
468
|
+
changes: changes.length,
|
|
469
|
+
untracked: parsed.untracked.length,
|
|
470
|
+
conflicts: parsed.conflicts.length,
|
|
471
|
+
},
|
|
472
|
+
generatedAt: Date.now(),
|
|
473
|
+
}
|
|
474
|
+
return rememberStatus(cwd, payload)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Attach `+/-` counts to the entries whose path has them.
|
|
479
|
+
* @param {object[]} entries - status entries.
|
|
480
|
+
* @param {Map<string, object>} counts - numstat by path.
|
|
481
|
+
* @returns {object[]} entries with `additions`/`deletions`.
|
|
482
|
+
*/
|
|
483
|
+
function mergeCounts(entries, counts) {
|
|
484
|
+
return entries.map((entry) => {
|
|
485
|
+
const count = counts.get(entry.path) ?? counts.get(entry.originalPath ?? '') ?? undefined
|
|
486
|
+
return {
|
|
487
|
+
...entry,
|
|
488
|
+
additions: count?.additions ?? 0,
|
|
489
|
+
deletions: count?.deletions ?? 0,
|
|
490
|
+
binary: count?.binary ?? false,
|
|
491
|
+
}
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function rememberStatus(cwd, value) {
|
|
496
|
+
if (statusCache.size > 32) statusCache.clear()
|
|
497
|
+
statusCache.set(cwd, { at: Date.now(), value })
|
|
498
|
+
return value
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/* ------------------------------------------------------------------ */
|
|
502
|
+
/* Commit graph */
|
|
503
|
+
/* ------------------------------------------------------------------ */
|
|
504
|
+
|
|
505
|
+
/** Record and field separators for `git log --pretty=format:`. */
|
|
506
|
+
const RECORD = '\x1e'
|
|
507
|
+
const FIELD = '\x1f'
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Parse one decorated `git log` stream.
|
|
511
|
+
* @param {string} raw - stdout.
|
|
512
|
+
* @returns {object[]} commits in the order git produced them.
|
|
513
|
+
*/
|
|
514
|
+
export function parseGraph(raw, remotes = ['origin']) {
|
|
515
|
+
const commits = []
|
|
516
|
+
for (const chunk of raw.split(RECORD)) {
|
|
517
|
+
const text = chunk.replace(/^\n/u, '')
|
|
518
|
+
if (text.trim() === '') continue
|
|
519
|
+
const [hash, parents, author, email, at, decorations, subject, ...bodyParts] = text.split(FIELD)
|
|
520
|
+
if (typeof hash !== 'string' || hash.length < 7) continue
|
|
521
|
+
commits.push({
|
|
522
|
+
hash,
|
|
523
|
+
short: hash.slice(0, 7),
|
|
524
|
+
parents: typeof parents === 'string' && parents !== '' ? parents.split(' ').filter(Boolean) : [],
|
|
525
|
+
author: author ?? '',
|
|
526
|
+
email: email ?? '',
|
|
527
|
+
at: Number.parseInt(at ?? '0', 10) || 0,
|
|
528
|
+
refs: parseDecorations(decorations ?? '', remotes),
|
|
529
|
+
subject: subject ?? '',
|
|
530
|
+
body: bodyParts.join(FIELD).trim(),
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
return commits
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Parse `%D` decorations into typed refs.
|
|
538
|
+
* `HEAD -> main, origin/main, tag: v1.0.0` becomes typed entries the panel can
|
|
539
|
+
* badge and act on.
|
|
540
|
+
*
|
|
541
|
+
* `%D` does not say whether a slashed name is a remote-tracking ref or a local
|
|
542
|
+
* branch, so the repository's remote names decide: only `origin/main` where
|
|
543
|
+
* `origin` is a configured remote is a remote ref, while `feat/dock` stays a
|
|
544
|
+
* local branch.
|
|
545
|
+
* @param {string} text - decoration field.
|
|
546
|
+
* @param {string[]} [remotes] - configured remote names; defaults to `origin`.
|
|
547
|
+
* @returns {object[]} refs in git's order.
|
|
548
|
+
*/
|
|
549
|
+
export function parseDecorations(text, remotes = ['origin']) {
|
|
550
|
+
const refs = []
|
|
551
|
+
const remotes$ = new Set(remotes.map((entry) => String(entry)))
|
|
552
|
+
for (const piece of text.split(',')) {
|
|
553
|
+
const token = piece.trim()
|
|
554
|
+
if (token === '') continue
|
|
555
|
+
if (token.startsWith('HEAD -> ')) {
|
|
556
|
+
const name = token.slice(8).trim()
|
|
557
|
+
refs.push({ kind: 'head', name })
|
|
558
|
+
continue
|
|
559
|
+
}
|
|
560
|
+
if (token === 'HEAD') {
|
|
561
|
+
refs.push({ kind: 'head', name: 'HEAD' })
|
|
562
|
+
continue
|
|
563
|
+
}
|
|
564
|
+
if (token.startsWith('tag: ')) {
|
|
565
|
+
refs.push({ kind: 'tag', name: token.slice(5).trim() })
|
|
566
|
+
continue
|
|
567
|
+
}
|
|
568
|
+
const slash = token.indexOf('/')
|
|
569
|
+
if (slash > 0 && remotes$.has(token.slice(0, slash))) {
|
|
570
|
+
refs.push({ kind: 'remote', name: token })
|
|
571
|
+
continue
|
|
572
|
+
}
|
|
573
|
+
refs.push({ kind: 'branch', name: token })
|
|
574
|
+
}
|
|
575
|
+
return refs
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Read the commit graph of one repository.
|
|
580
|
+
* @param {string} cwd - absolute directory.
|
|
581
|
+
* @param {object} options - `limit`, `all` (every ref) and cancellation.
|
|
582
|
+
* @returns {Promise<object>} graph payload.
|
|
583
|
+
*/
|
|
584
|
+
async function readGraph(cwd, { limit, all, signal }) {
|
|
585
|
+
const repository = await locateRepository(cwd, signal)
|
|
586
|
+
if (repository.repo !== true) return { repo: false }
|
|
587
|
+
|
|
588
|
+
const depth = Math.min(Math.max(Number.parseInt(String(limit ?? DEFAULT_GRAPH_LIMIT), 10) || DEFAULT_GRAPH_LIMIT, 1), MAX_GRAPH_LIMIT)
|
|
589
|
+
const args = [
|
|
590
|
+
'log',
|
|
591
|
+
`--max-count=${depth + 1}`,
|
|
592
|
+
'--date-order',
|
|
593
|
+
all === true ? '--all' : 'HEAD',
|
|
594
|
+
`--pretty=format:${RECORD}%H${FIELD}%P${FIELD}%an${FIELD}%ae${FIELD}%at${FIELD}%D${FIELD}%s${FIELD}%b`,
|
|
595
|
+
]
|
|
596
|
+
|
|
597
|
+
const [result, remoteList] = await Promise.all([
|
|
598
|
+
runGit(cwd, args, { signal, maxBuffer: 8 * 1024 * 1024 }),
|
|
599
|
+
runGit(cwd, ['remote'], { signal }),
|
|
600
|
+
])
|
|
601
|
+
const remotes = remoteList.exitCode === 0 ? remoteList.stdout.split('\n').map((line) => line.trim()).filter(Boolean) : []
|
|
602
|
+
if (result.exitCode !== 0) {
|
|
603
|
+
// An unborn HEAD has no history yet; that is an empty graph, not an error.
|
|
604
|
+
const message = result.stderr.toLowerCase()
|
|
605
|
+
if (message.includes('does not have any commits') || message.includes('unknown revision') || message.includes('bad revision')) {
|
|
606
|
+
return { repo: true, root: repository.root, name: repository.name, branch: null, commits: [], truncated: false, all: all === true }
|
|
607
|
+
}
|
|
608
|
+
throw new PanelError('git-failed', result.stderr.trim() || 'git log failed')
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const commits = parseGraph(result.stdout, remotes)
|
|
612
|
+
const truncated = commits.length > depth
|
|
613
|
+
return {
|
|
614
|
+
repo: true,
|
|
615
|
+
root: repository.root,
|
|
616
|
+
name: repository.name,
|
|
617
|
+
commits: truncated ? commits.slice(0, depth) : commits,
|
|
618
|
+
truncated,
|
|
619
|
+
all: all === true,
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/* ------------------------------------------------------------------ */
|
|
624
|
+
/* Diffs */
|
|
625
|
+
/* ------------------------------------------------------------------ */
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Render one untracked file as a synthetic "new file" patch, so the diff tab can
|
|
629
|
+
* show what a first-time file actually contains.
|
|
630
|
+
* @param {string} root - repository root.
|
|
631
|
+
* @param {string} path - repository-relative path.
|
|
632
|
+
* @returns {Promise<object>} `{ patch, binary, meta }`.
|
|
633
|
+
*/
|
|
634
|
+
async function untrackedPatch(root, path) {
|
|
635
|
+
const absolute = resolve(root, path)
|
|
636
|
+
try {
|
|
637
|
+
const info = await stat(absolute)
|
|
638
|
+
if (info.size > MAX_PREVIEW_BYTES) {
|
|
639
|
+
return { patch: '', binary: true, meta: `新文件(${Math.round(info.size / 1024)} KiB,超过预览上限)` }
|
|
640
|
+
}
|
|
641
|
+
const bytes = await readFile(absolute)
|
|
642
|
+
if (bytes.includes(0)) return { patch: '', binary: true, meta: '新文件(二进制)' }
|
|
643
|
+
const text = bytes.toString('utf8')
|
|
644
|
+
const lines = text.split('\n')
|
|
645
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
646
|
+
const body = lines.map((line) => `+${line}`).join('\n')
|
|
647
|
+
return {
|
|
648
|
+
patch: `--- /dev/null\n+++ b/${path}\n@@ -0,0 +1,${lines.length} @@\n${body}\n`,
|
|
649
|
+
binary: false,
|
|
650
|
+
meta: `新文件(${lines.length} 行)`,
|
|
651
|
+
}
|
|
652
|
+
} catch (error) {
|
|
653
|
+
return { patch: '', binary: true, meta: `新文件(无法读取:${error instanceof Error ? error.message : String(error)})` }
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Produce the unified diff one file needs.
|
|
659
|
+
* @param {string} cwd - absolute directory.
|
|
660
|
+
* @param {object} request - `{ path, staged, untracked, hash }`.
|
|
661
|
+
* @param {AbortSignal} [signal] - request signal.
|
|
662
|
+
* @returns {Promise<object>} patch payload.
|
|
663
|
+
*/
|
|
664
|
+
async function readDiff(cwd, request, signal) {
|
|
665
|
+
const repository = await locateRepository(cwd, signal)
|
|
666
|
+
if (repository.repo !== true) return { repo: false }
|
|
667
|
+
|
|
668
|
+
const hash = typeof request.hash === 'string' && /^[0-9a-f]{4,64}$/iu.test(request.hash) ? request.hash : null
|
|
669
|
+
if (hash !== null) {
|
|
670
|
+
const patch = await mustGit(
|
|
671
|
+
cwd,
|
|
672
|
+
['show', '--no-color', '--format=%H%n%an <%ae>%n%ad%n%s%n%b', '--date=iso', '-U3', hash, '--'],
|
|
673
|
+
{ signal, maxBuffer: 8 * 1024 * 1024 },
|
|
674
|
+
)
|
|
675
|
+
return { repo: true, root: repository.root, patch, binary: false, meta: hash }
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const path = typeof request.path === 'string' && request.path !== '' ? containedPath(repository.root, request.path) : null
|
|
679
|
+
const staged = request.staged === true
|
|
680
|
+
const untracked = request.untracked === true
|
|
681
|
+
|
|
682
|
+
if (path === null) {
|
|
683
|
+
if (request.all === true) {
|
|
684
|
+
// The preview page for "every change" shows the staged part, the working
|
|
685
|
+
// tree part and each new file, in that order — the same three groups the
|
|
686
|
+
// panel lists.
|
|
687
|
+
const pieces = []
|
|
688
|
+
const stagedDiff = await runGit(cwd, ['diff', '--cached', '--no-color', '-U3'], { signal, maxBuffer: 8 * 1024 * 1024 })
|
|
689
|
+
if (stagedDiff.stdout.trim() !== '') pieces.push(stagedDiff.stdout)
|
|
690
|
+
const worktreeDiff = await runGit(cwd, ['diff', '--no-color', '-U3'], { signal, maxBuffer: 8 * 1024 * 1024 })
|
|
691
|
+
if (worktreeDiff.stdout.trim() !== '') pieces.push(worktreeDiff.stdout)
|
|
692
|
+
const status = await readStatus(cwd, signal, true)
|
|
693
|
+
for (const entry of status.untracked ?? []) {
|
|
694
|
+
const synthetic = await untrackedPatch(repository.root, entry.path)
|
|
695
|
+
if (synthetic.patch !== '') pieces.push(synthetic.patch)
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
repo: true,
|
|
699
|
+
root: repository.root,
|
|
700
|
+
patch: pieces.join('\n'),
|
|
701
|
+
binary: false,
|
|
702
|
+
meta: 'all',
|
|
703
|
+
files: status.untracked.length + status.staged.length + status.changes.length,
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const args = staged ? ['diff', '--cached', '--no-color', '-U3'] : ['diff', '--no-color', '-U3']
|
|
707
|
+
const patch = await mustGit(cwd, args, { signal, maxBuffer: 8 * 1024 * 1024 })
|
|
708
|
+
return { repo: true, root: repository.root, patch, binary: false, meta: staged ? 'staged' : 'worktree' }
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (untracked === true) {
|
|
712
|
+
const synthetic = await untrackedPatch(repository.root, path)
|
|
713
|
+
return { repo: true, root: repository.root, path, ...synthetic }
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const args = staged
|
|
717
|
+
? ['diff', '--cached', '--no-color', '-U3', '--', path]
|
|
718
|
+
: ['diff', '--no-color', '-U3', '--', path]
|
|
719
|
+
const patch = await mustGit(cwd, args, { signal, maxBuffer: 8 * 1024 * 1024 })
|
|
720
|
+
return {
|
|
721
|
+
repo: true,
|
|
722
|
+
root: repository.root,
|
|
723
|
+
path,
|
|
724
|
+
patch,
|
|
725
|
+
binary: patch.includes('Binary files'),
|
|
726
|
+
meta: staged ? 'staged' : 'worktree',
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Read one commit's file list with its counts, for the node description panel.
|
|
732
|
+
* @param {string} cwd - absolute directory.
|
|
733
|
+
* @param {string} hash - commit id (validated by the caller).
|
|
734
|
+
* @param {AbortSignal} [signal] - request signal.
|
|
735
|
+
* @returns {Promise<object>} file rows.
|
|
736
|
+
*/
|
|
737
|
+
async function readCommit(cwd, hash, signal) {
|
|
738
|
+
const repository = await locateRepository(cwd, signal)
|
|
739
|
+
if (repository.repo !== true) return { repo: false }
|
|
740
|
+
|
|
741
|
+
const meta = await mustGit(
|
|
742
|
+
cwd,
|
|
743
|
+
['show', '--no-color', '--no-patch', '--date=iso', '--pretty=format:%H%x1f%P%x1f%an%x1f%ae%x1f%aI%x1f%s%x1f%b', hash],
|
|
744
|
+
{ signal },
|
|
745
|
+
)
|
|
746
|
+
const [full, parents, author, email, at, subject, ...body] = meta.split(FIELD)
|
|
747
|
+
const numstat = await runGit(cwd, ['show', '--numstat', '-z', '--format=', hash], { signal })
|
|
748
|
+
const nameStatus = await runGit(cwd, ['show', '--name-status', '-z', '--format=', hash], { signal })
|
|
749
|
+
|
|
750
|
+
return {
|
|
751
|
+
repo: true,
|
|
752
|
+
root: repository.root,
|
|
753
|
+
commit: {
|
|
754
|
+
hash: full,
|
|
755
|
+
short: String(full).slice(0, 7),
|
|
756
|
+
parents: typeof parents === 'string' && parents !== '' ? parents.split(' ') : [],
|
|
757
|
+
author: author ?? '',
|
|
758
|
+
email: email ?? '',
|
|
759
|
+
at: at ?? '',
|
|
760
|
+
subject: subject ?? '',
|
|
761
|
+
body: body.join(FIELD).trim(),
|
|
762
|
+
files: commitFiles(numstat.stdout, nameStatus.stdout),
|
|
763
|
+
},
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Merge `--numstat` and `--name-status` into one row per file.
|
|
769
|
+
* @param {string} numstat - stdout.
|
|
770
|
+
* @param {string} nameStatus - stdout.
|
|
771
|
+
* @returns {object[]} rows.
|
|
772
|
+
*/
|
|
773
|
+
export function commitFiles(numstat, nameStatus) {
|
|
774
|
+
const counts = parseNumstat(numstat)
|
|
775
|
+
const statuses = new Map()
|
|
776
|
+
const fields = nameStatus.split('\0')
|
|
777
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
778
|
+
const code = fields[index]
|
|
779
|
+
if (code === '') continue
|
|
780
|
+
if (code[0] === 'R' || code[0] === 'C') {
|
|
781
|
+
const from = fields[index + 1] ?? ''
|
|
782
|
+
const to = fields[index + 2] ?? ''
|
|
783
|
+
index += 2
|
|
784
|
+
if (to !== '') statuses.set(to, { status: code[0], from })
|
|
785
|
+
continue
|
|
786
|
+
}
|
|
787
|
+
const path = fields[index + 1] ?? ''
|
|
788
|
+
index += 1
|
|
789
|
+
if (path !== '') statuses.set(path, { status: code[0] ?? 'M', from: undefined })
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const rows = []
|
|
793
|
+
for (const [path, info] of statuses) {
|
|
794
|
+
const count = counts.get(path)
|
|
795
|
+
rows.push({
|
|
796
|
+
path,
|
|
797
|
+
status: info.status,
|
|
798
|
+
from: info.from,
|
|
799
|
+
additions: count?.additions ?? 0,
|
|
800
|
+
deletions: count?.deletions ?? 0,
|
|
801
|
+
binary: count?.binary ?? false,
|
|
802
|
+
})
|
|
803
|
+
}
|
|
804
|
+
return rows
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/* ------------------------------------------------------------------ */
|
|
808
|
+
/* Actions */
|
|
809
|
+
/* ------------------------------------------------------------------ */
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Resolve the paths one write action names and refuse an empty list.
|
|
813
|
+
* @param {object} repository - located repository.
|
|
814
|
+
* @param {unknown} paths - caller paths.
|
|
815
|
+
* @returns {string[]} contained relative paths.
|
|
816
|
+
*/
|
|
817
|
+
function containedPaths(repository, paths) {
|
|
818
|
+
if (!Array.isArray(paths) || paths.length === 0) throw new PanelError('bad-path', 'paths must be a non-empty array')
|
|
819
|
+
if (paths.length > 500) throw new PanelError('bad-path', 'at most 500 paths per request')
|
|
820
|
+
return paths.map((path) => containedPath(repository.root, path))
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* `resolve`: take one side of a conflicted file, then stage it.
|
|
825
|
+
*
|
|
826
|
+
* `ours` keeps HEAD's version and `theirs` the incoming one — the panel-level
|
|
827
|
+
* equivalent of the merge editor's "Accept Current / Incoming Change". Staging
|
|
828
|
+
* afterwards is exactly what marks the conflict resolved, so the file leaves
|
|
829
|
+
* Merge Changes and the commit button unblocks.
|
|
830
|
+
*/
|
|
831
|
+
async function resolveConflict(cwd, request, signal) {
|
|
832
|
+
const repository = await locateRepository(cwd, signal)
|
|
833
|
+
if (repository.repo !== true) throw new PanelError('no-repo', 'not a Git work tree')
|
|
834
|
+
const path = containedPath(repository.root, request.path)
|
|
835
|
+
const mode = request.mode === 'theirs' ? 'theirs' : 'ours'
|
|
836
|
+
const checked = await runGit(cwd, ['checkout', `--${mode}`, '--', path], { signal, readOnly: false, timeoutMs: WRITE_TIMEOUT_MS })
|
|
837
|
+
if (checked.exitCode !== 0) {
|
|
838
|
+
throw new PanelError('git-failed', checked.stderr.trim() || `git checkout --${mode} -- ${path} failed`)
|
|
839
|
+
}
|
|
840
|
+
await mustGit(cwd, ['add', '--', path], { signal, readOnly: false, timeoutMs: WRITE_TIMEOUT_MS })
|
|
841
|
+
statusCache.delete(cwd)
|
|
842
|
+
return { ok: true, action: 'resolve', mode, path }
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* `git stage`: add the named paths (deletions included).
|
|
847
|
+
*/
|
|
848
|
+
async function stage(cwd, request, signal) {
|
|
849
|
+
const repository = await locateRepository(cwd, signal)
|
|
850
|
+
if (repository.repo !== true) throw new PanelError('no-repo', 'not a Git work tree')
|
|
851
|
+
const paths = containedPaths(repository, request.paths)
|
|
852
|
+
await mustGit(cwd, ['add', '-A', '--', ...paths], { signal, readOnly: false, timeoutMs: WRITE_TIMEOUT_MS })
|
|
853
|
+
statusCache.delete(cwd)
|
|
854
|
+
return { ok: true, action: 'stage', paths }
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* `git unstage`: drop the named paths from the index, keeping the work tree.
|
|
859
|
+
*/
|
|
860
|
+
async function unstage(cwd, request, signal) {
|
|
861
|
+
const repository = await locateRepository(cwd, signal)
|
|
862
|
+
if (repository.repo !== true) throw new PanelError('no-repo', 'not a Git work tree')
|
|
863
|
+
const paths = containedPaths(repository, request.paths)
|
|
864
|
+
const reset = await runGit(cwd, ['restore', '--staged', '--', ...paths], { signal, readOnly: false, timeoutMs: WRITE_TIMEOUT_MS })
|
|
865
|
+
if (reset.exitCode !== 0) {
|
|
866
|
+
await mustGit(cwd, ['reset', '-q', 'HEAD', '--', ...paths], { signal, readOnly: false, timeoutMs: WRITE_TIMEOUT_MS })
|
|
867
|
+
}
|
|
868
|
+
statusCache.delete(cwd)
|
|
869
|
+
return { ok: true, action: 'unstage', paths }
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* `discard`: restore tracked paths from HEAD and delete untracked ones.
|
|
874
|
+
*
|
|
875
|
+
* A path that is staged but has no HEAD version (a brand-new file) makes
|
|
876
|
+
* `git restore --source=HEAD` refuse the whole batch, so the batch falls back to
|
|
877
|
+
* one path at a time: HEAD-known paths are restored, HEAD-unknown ones are
|
|
878
|
+
* unstaged and deleted — the same end state the user means by "discard".
|
|
879
|
+
* The browser asks for confirmation; this path refuses anything outside the
|
|
880
|
+
* work tree, and never deletes a directory the request did not name itself.
|
|
881
|
+
*/
|
|
882
|
+
async function discard(cwd, request, signal) {
|
|
883
|
+
const repository = await locateRepository(cwd, signal)
|
|
884
|
+
if (repository.repo !== true) throw new PanelError('no-repo', 'not a Git work tree')
|
|
885
|
+
const tracked = Array.isArray(request.paths) ? request.paths : []
|
|
886
|
+
const untracked = Array.isArray(request.untracked) ? request.untracked : []
|
|
887
|
+
if (tracked.length === 0 && untracked.length === 0) throw new PanelError('bad-path', 'nothing to discard')
|
|
888
|
+
|
|
889
|
+
const restored = []
|
|
890
|
+
const removed = []
|
|
891
|
+
const paths = tracked.length > 0 ? containedPaths(repository, tracked) : []
|
|
892
|
+
|
|
893
|
+
if (paths.length > 0) {
|
|
894
|
+
const writeOptions = { signal, readOnly: false, timeoutMs: WRITE_TIMEOUT_MS }
|
|
895
|
+
const bulk = await runGit(cwd, ['restore', '--source=HEAD', '--staged', '--worktree', '--', ...paths], writeOptions)
|
|
896
|
+
if (bulk.exitCode === 0) {
|
|
897
|
+
restored.push(...paths)
|
|
898
|
+
} else {
|
|
899
|
+
for (const path of paths) {
|
|
900
|
+
if (signal?.aborted === true) break
|
|
901
|
+
const single = await runGit(cwd, ['restore', '--source=HEAD', '--staged', '--worktree', '--', path], writeOptions)
|
|
902
|
+
if (single.exitCode === 0) {
|
|
903
|
+
restored.push(path)
|
|
904
|
+
continue
|
|
905
|
+
}
|
|
906
|
+
const known = await runGit(cwd, ['cat-file', '-e', `HEAD:${path}`], { signal })
|
|
907
|
+
if (known.exitCode === 0) {
|
|
908
|
+
// HEAD has a version, so a refused restore is a real failure: do not
|
|
909
|
+
// delete content to make the click look like it worked.
|
|
910
|
+
throw new PanelError('git-failed', single.stderr.trim() || `git restore -- ${path} failed`, { path })
|
|
911
|
+
}
|
|
912
|
+
// New in the index and absent from HEAD: unstage, then delete.
|
|
913
|
+
await runGit(cwd, ['rm', '--cached', '-q', '--', path], writeOptions)
|
|
914
|
+
await rm(resolve(repository.root, path), { recursive: true, force: true })
|
|
915
|
+
removed.push(path)
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (untracked.length > 0) {
|
|
921
|
+
const paths$ = containedPaths(repository, untracked)
|
|
922
|
+
for (const path of paths$) {
|
|
923
|
+
if (signal?.aborted === true) break
|
|
924
|
+
await rm(resolve(repository.root, path), { recursive: true, force: true })
|
|
925
|
+
removed.push(path)
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
statusCache.delete(cwd)
|
|
930
|
+
return { ok: true, action: 'discard', paths: restored, removed }
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* One commit, with the message on stdin so no quoting rule can mangle it.
|
|
935
|
+
* Options mirror the Git module's commit matrix: amend, no-verify, signoff,
|
|
936
|
+
* allow-empty, and "smart commit" (stage everything first).
|
|
937
|
+
*/
|
|
938
|
+
async function commit(cwd, request, signal) {
|
|
939
|
+
const repository = await locateRepository(cwd, signal)
|
|
940
|
+
if (repository.repo !== true) throw new PanelError('no-repo', 'not a Git work tree')
|
|
941
|
+
|
|
942
|
+
const message = typeof request.message === 'string' ? request.message : ''
|
|
943
|
+
const subject = message.split('\n').find((line) => line.trim() !== '') ?? ''
|
|
944
|
+
if (subject.trim() === '' && request.allowEmpty !== true) {
|
|
945
|
+
throw new PanelError('empty-message', '提交信息不能为空')
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
if (request.stageAll === true) {
|
|
949
|
+
await mustGit(cwd, ['add', '-A'], { signal, readOnly: false, timeoutMs: WRITE_TIMEOUT_MS })
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const args = ['commit', '-F', '-', '--cleanup=whitespace']
|
|
953
|
+
if (request.amend === true) args.push('--amend')
|
|
954
|
+
if (request.noVerify === true) args.push('--no-verify')
|
|
955
|
+
if (request.signoff === true) args.push('--signoff')
|
|
956
|
+
if (request.allowEmpty === true) args.push('--allow-empty')
|
|
957
|
+
|
|
958
|
+
const result = await runGit(cwd, args, {
|
|
959
|
+
signal,
|
|
960
|
+
readOnly: false,
|
|
961
|
+
timeoutMs: WRITE_TIMEOUT_MS,
|
|
962
|
+
stdin: message === '' ? '\n' : message,
|
|
963
|
+
})
|
|
964
|
+
if (result.exitCode !== 0) {
|
|
965
|
+
throw new PanelError('commit-failed', result.stderr.trim() || result.stdout.trim() || 'git commit failed')
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const head = await runGit(cwd, ['rev-parse', '--short', 'HEAD'], { signal })
|
|
969
|
+
const branch = await runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'], { signal })
|
|
970
|
+
statusCache.delete(cwd)
|
|
971
|
+
return {
|
|
972
|
+
ok: true,
|
|
973
|
+
action: 'commit',
|
|
974
|
+
hash: head.exitCode === 0 ? head.stdout.trim() : null,
|
|
975
|
+
branch: branch.exitCode === 0 ? branch.stdout.trim() : null,
|
|
976
|
+
summary: result.stdout.trim(),
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** Undo the last commit but keep its content staged — the Git module's `undoCommit`. */
|
|
981
|
+
async function undoCommit(cwd, request, signal) {
|
|
982
|
+
const repository = await locateRepository(cwd, signal)
|
|
983
|
+
if (repository.repo !== true) throw new PanelError('no-repo', 'not a Git work tree')
|
|
984
|
+
const head = await runGit(cwd, ['rev-parse', '--verify', '--quiet', 'HEAD'], { signal })
|
|
985
|
+
if (head.exitCode !== 0) throw new PanelError('nothing-to-undo', '还没有任何提交')
|
|
986
|
+
const soft = await runGit(cwd, ['reset', '--soft', 'HEAD~1'], { signal, readOnly: false, timeoutMs: WRITE_TIMEOUT_MS })
|
|
987
|
+
if (soft.exitCode !== 0) {
|
|
988
|
+
await mustGit(cwd, ['update-ref', '-d', 'HEAD'], { signal, readOnly: false, timeoutMs: WRITE_TIMEOUT_MS })
|
|
989
|
+
}
|
|
990
|
+
statusCache.delete(cwd)
|
|
991
|
+
return { ok: true, action: 'undoCommit' }
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** Network verbs. Each is one git command with its own timeout and honest stderr. */
|
|
995
|
+
async function network(cwd, request, signal) {
|
|
996
|
+
const repository = await locateRepository(cwd, signal)
|
|
997
|
+
if (repository.repo !== true) throw new PanelError('no-repo', 'not a Git work tree')
|
|
998
|
+
const mode = String(request.mode ?? 'fetch')
|
|
999
|
+
|
|
1000
|
+
const steps = []
|
|
1001
|
+
if (mode === 'fetch' || mode === 'sync') steps.push(['fetch', '--prune', '--all'])
|
|
1002
|
+
if (mode === 'pull' || mode === 'sync') steps.push(['pull', '--no-edit'])
|
|
1003
|
+
if (mode === 'push' || mode === 'sync') steps.push(['push'])
|
|
1004
|
+
|
|
1005
|
+
const output = []
|
|
1006
|
+
|
|
1007
|
+
/** Push once; a branch with no upstream gets `-u origin <branch>` instead. */
|
|
1008
|
+
async function pushWithUpstream() {
|
|
1009
|
+
const first = await runGit(cwd, ['push'], { signal, readOnly: false, timeoutMs: WRITE_TIMEOUT_MS })
|
|
1010
|
+
output.push({ args: ['push'], exitCode: first.exitCode, stdout: first.stdout.trim(), stderr: first.stderr.trim() })
|
|
1011
|
+
if (first.exitCode === 0) return
|
|
1012
|
+
const text = `${first.stderr}\n${first.stdout}`
|
|
1013
|
+
if (!/no upstream branch|has no upstream|--set-upstream/i.test(text)) {
|
|
1014
|
+
throw new PanelError('git-failed', first.stderr.trim() || first.stdout.trim() || 'git push failed', { steps: output })
|
|
1015
|
+
}
|
|
1016
|
+
const head = await runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'], { signal })
|
|
1017
|
+
const branch = head.stdout.trim()
|
|
1018
|
+
const args = ['push', '-u', 'origin', branch]
|
|
1019
|
+
const second = await runGit(cwd, args, { signal, readOnly: false, timeoutMs: WRITE_TIMEOUT_MS })
|
|
1020
|
+
output.push({ args, exitCode: second.exitCode, stdout: second.stdout.trim(), stderr: second.stderr.trim() })
|
|
1021
|
+
if (second.exitCode !== 0) {
|
|
1022
|
+
throw new PanelError('git-failed', second.stderr.trim() || second.stdout.trim() || 'git push failed', { steps: output })
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
for (const args of steps) {
|
|
1027
|
+
if (signal?.aborted === true) break
|
|
1028
|
+
if (args[0] === 'push') {
|
|
1029
|
+
await pushWithUpstream()
|
|
1030
|
+
continue
|
|
1031
|
+
}
|
|
1032
|
+
const result = await runGit(cwd, args, { signal, readOnly: false, timeoutMs: WRITE_TIMEOUT_MS })
|
|
1033
|
+
output.push({ args, exitCode: result.exitCode, stdout: result.stdout.trim(), stderr: result.stderr.trim() })
|
|
1034
|
+
if (result.exitCode !== 0) {
|
|
1035
|
+
throw new PanelError('git-failed', result.stderr.trim() || result.stdout.trim() || `git ${args[0]} failed`, {
|
|
1036
|
+
steps: output,
|
|
1037
|
+
})
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
statusCache.delete(cwd)
|
|
1042
|
+
return { ok: true, action: 'network', mode, steps: output }
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* List local branches and tags with their upstream, for the graph header and the
|
|
1047
|
+
* checkout menu.
|
|
1048
|
+
*/
|
|
1049
|
+
async function branches(cwd, request, signal) {
|
|
1050
|
+
const repository = await locateRepository(cwd, signal)
|
|
1051
|
+
if (repository.repo !== true) return { repo: false }
|
|
1052
|
+
const format = '%(refname:short)%1f%(objectname:short)%1f%(upstream:short)%1f%(HEAD)%1f%(committerdate:iso)'
|
|
1053
|
+
const [local, remote, tags] = await Promise.all([
|
|
1054
|
+
runGit(cwd, ['for-each-ref', `--format=${format}`, 'refs/heads'], { signal }),
|
|
1055
|
+
runGit(cwd, ['for-each-ref', '--format=%(refname:short)', 'refs/remotes'], { signal }),
|
|
1056
|
+
runGit(cwd, ['for-each-ref', '--format=%(refname:short)', 'refs/tags'], { signal }),
|
|
1057
|
+
])
|
|
1058
|
+
return {
|
|
1059
|
+
repo: true,
|
|
1060
|
+
root: repository.root,
|
|
1061
|
+
local: parseRefList(local.stdout, true),
|
|
1062
|
+
remote: parseRefList(remote.stdout, false),
|
|
1063
|
+
tags: parseRefList(tags.stdout, false),
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Parse one `for-each-ref` dump.
|
|
1069
|
+
* @param {string} raw - stdout.
|
|
1070
|
+
* @param {boolean} detailed - whether the format carried the extra fields.
|
|
1071
|
+
* @returns {object[]} ref rows.
|
|
1072
|
+
*/
|
|
1073
|
+
export function parseRefList(raw, detailed) {
|
|
1074
|
+
const rows = []
|
|
1075
|
+
for (const line of raw.split('\n')) {
|
|
1076
|
+
const text = line.trim()
|
|
1077
|
+
if (text === '') continue
|
|
1078
|
+
if (!detailed) {
|
|
1079
|
+
rows.push({ name: text })
|
|
1080
|
+
continue
|
|
1081
|
+
}
|
|
1082
|
+
const [name, oid, upstream, head, date] = text.split(FIELD)
|
|
1083
|
+
if (name === undefined || name === '') continue
|
|
1084
|
+
rows.push({
|
|
1085
|
+
name,
|
|
1086
|
+
oid: oid ?? '',
|
|
1087
|
+
upstream: upstream ?? '',
|
|
1088
|
+
current: head === '*',
|
|
1089
|
+
date: date ?? '',
|
|
1090
|
+
})
|
|
1091
|
+
}
|
|
1092
|
+
return rows
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/** Switch branches, refusing a dirty work tree the way git itself would. */
|
|
1096
|
+
async function checkout(cwd, request, signal) {
|
|
1097
|
+
const repository = await locateRepository(cwd, signal)
|
|
1098
|
+
if (repository.repo !== true) throw new PanelError('no-repo', 'not a Git work tree')
|
|
1099
|
+
const target = request.name
|
|
1100
|
+
if (typeof target !== 'string' || target === '' || target.length > 512 || target.startsWith('-')) {
|
|
1101
|
+
throw new PanelError('bad-ref', '分支名不合法')
|
|
1102
|
+
}
|
|
1103
|
+
const result = await runGit(cwd, ['checkout', target], { signal, readOnly: false, timeoutMs: WRITE_TIMEOUT_MS })
|
|
1104
|
+
if (result.exitCode !== 0) {
|
|
1105
|
+
throw new PanelError('checkout-failed', result.stderr.trim() || result.stdout.trim() || 'git checkout failed')
|
|
1106
|
+
}
|
|
1107
|
+
statusCache.delete(cwd)
|
|
1108
|
+
return { ok: true, action: 'checkout', name: target }
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** Stash the work tree (untracked included) or pop the newest entry. */
|
|
1112
|
+
async function stash(cwd, request, signal) {
|
|
1113
|
+
const repository = await locateRepository(cwd, signal)
|
|
1114
|
+
if (repository.repo !== true) throw new PanelError('no-repo', 'not a Git work tree')
|
|
1115
|
+
const message = typeof request.message === 'string' && request.message.trim() !== '' ? request.message.trim() : ''
|
|
1116
|
+
const args = request.mode === 'pop' ? ['stash', 'pop'] : ['stash', 'push', '--include-untracked']
|
|
1117
|
+
if (request.mode !== 'pop' && message !== '') args.push('-m', message)
|
|
1118
|
+
const result = await runGit(cwd, args, { signal, readOnly: false, timeoutMs: WRITE_TIMEOUT_MS })
|
|
1119
|
+
if (result.exitCode !== 0) {
|
|
1120
|
+
throw new PanelError('stash-failed', result.stderr.trim() || result.stdout.trim() || 'git stash failed')
|
|
1121
|
+
}
|
|
1122
|
+
statusCache.delete(cwd)
|
|
1123
|
+
return { ok: true, action: 'stash', mode: request.mode ?? 'push', summary: result.stdout.trim() }
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/* ------------------------------------------------------------------ */
|
|
1127
|
+
/* Route */
|
|
1128
|
+
/* ------------------------------------------------------------------ */
|
|
1129
|
+
|
|
1130
|
+
/** Every action this route accepts. */
|
|
1131
|
+
const ACTIONS = new Map([
|
|
1132
|
+
['status', 'read'],
|
|
1133
|
+
['graph', 'read'],
|
|
1134
|
+
['diff', 'read'],
|
|
1135
|
+
['commitDetail', 'read'],
|
|
1136
|
+
['branches', 'read'],
|
|
1137
|
+
['stage', 'write'],
|
|
1138
|
+
['unstage', 'write'],
|
|
1139
|
+
['discard', 'write'],
|
|
1140
|
+
['resolve', 'write'],
|
|
1141
|
+
['commit', 'write'],
|
|
1142
|
+
['undoCommit', 'write'],
|
|
1143
|
+
['network', 'write'],
|
|
1144
|
+
['checkout', 'write'],
|
|
1145
|
+
['stash', 'write'],
|
|
1146
|
+
])
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Dispatch one request.
|
|
1150
|
+
* @param {object} request - parsed body.
|
|
1151
|
+
* @param {AbortSignal} signal - request signal.
|
|
1152
|
+
* @returns {Promise<object>} the value to wrap in `{ ok: true, value }`.
|
|
1153
|
+
*/
|
|
1154
|
+
export async function perform(request, signal) {
|
|
1155
|
+
if (request === null || typeof request !== 'object' || Array.isArray(request)) {
|
|
1156
|
+
throw new PanelError('bad-request', 'body must be a JSON object')
|
|
1157
|
+
}
|
|
1158
|
+
const action = String(request.action ?? '')
|
|
1159
|
+
if (!ACTIONS.has(action)) throw new PanelError('bad-action', `unknown action: ${action}`)
|
|
1160
|
+
const cwd = readCwd(request.cwd)
|
|
1161
|
+
|
|
1162
|
+
switch (action) {
|
|
1163
|
+
case 'status':
|
|
1164
|
+
return readStatus(cwd, signal, request.force === true)
|
|
1165
|
+
case 'graph':
|
|
1166
|
+
return readGraph(cwd, { limit: request.limit, all: request.all === true, signal })
|
|
1167
|
+
case 'diff':
|
|
1168
|
+
return readDiff(cwd, request, signal)
|
|
1169
|
+
case 'commitDetail': {
|
|
1170
|
+
if (typeof request.hash !== 'string' || !/^[0-9a-f]{4,64}$/iu.test(request.hash)) {
|
|
1171
|
+
throw new PanelError('bad-ref', 'hash 不合法')
|
|
1172
|
+
}
|
|
1173
|
+
return readCommit(cwd, request.hash, signal)
|
|
1174
|
+
}
|
|
1175
|
+
case 'branches':
|
|
1176
|
+
return branches(cwd, request, signal)
|
|
1177
|
+
case 'resolve':
|
|
1178
|
+
return resolveConflict(cwd, request, signal)
|
|
1179
|
+
case 'stage':
|
|
1180
|
+
return stage(cwd, request, signal)
|
|
1181
|
+
case 'unstage':
|
|
1182
|
+
return unstage(cwd, request, signal)
|
|
1183
|
+
case 'discard':
|
|
1184
|
+
return discard(cwd, request, signal)
|
|
1185
|
+
case 'commit':
|
|
1186
|
+
return commit(cwd, request, signal)
|
|
1187
|
+
case 'undoCommit':
|
|
1188
|
+
return undoCommit(cwd, request, signal)
|
|
1189
|
+
case 'network':
|
|
1190
|
+
return network(cwd, request, signal)
|
|
1191
|
+
case 'checkout':
|
|
1192
|
+
return checkout(cwd, request, signal)
|
|
1193
|
+
case 'stash':
|
|
1194
|
+
return stash(cwd, request, signal)
|
|
1195
|
+
default:
|
|
1196
|
+
throw new PanelError('bad-action', `unknown action: ${action}`)
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/** Success envelope. */
|
|
1201
|
+
function ok(value) {
|
|
1202
|
+
return { ok: true, value }
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/** Failure envelope; `code` is what the browser translates. */
|
|
1206
|
+
function fail(error) {
|
|
1207
|
+
const known = error instanceof PanelError
|
|
1208
|
+
return {
|
|
1209
|
+
ok: false,
|
|
1210
|
+
error: {
|
|
1211
|
+
code: known ? error.code : 'failed',
|
|
1212
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1213
|
+
details: known ? (error.details ?? {}) : {},
|
|
1214
|
+
},
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Mount the route. Everything that touches a repository lives behind this one
|
|
1220
|
+
* authenticated POST, so a browser cannot ask for anything else.
|
|
1221
|
+
* @param {object} ctx - Host plugin context.
|
|
1222
|
+
*/
|
|
1223
|
+
export function apply(ctx) {
|
|
1224
|
+
ctx.inject(['connection'], (scoped) => {
|
|
1225
|
+
const connection = scoped.get('connection')
|
|
1226
|
+
if (connection === undefined || connection === null) return
|
|
1227
|
+
const routes = connection.fetch
|
|
1228
|
+
if (routes === undefined || routes === null || typeof routes.register !== 'function') return
|
|
1229
|
+
|
|
1230
|
+
scoped.effect(
|
|
1231
|
+
() =>
|
|
1232
|
+
routes.register({
|
|
1233
|
+
path: ROUTE,
|
|
1234
|
+
methods: ['POST'],
|
|
1235
|
+
requestBody: 'buffered',
|
|
1236
|
+
fetch: async (request) => {
|
|
1237
|
+
let payload
|
|
1238
|
+
try {
|
|
1239
|
+
payload = await request.json()
|
|
1240
|
+
} catch {
|
|
1241
|
+
return Response.json(fail(new PanelError('bad-request', 'body must be JSON')))
|
|
1242
|
+
}
|
|
1243
|
+
try {
|
|
1244
|
+
return Response.json(ok(await perform(payload, request.signal)))
|
|
1245
|
+
} catch (error) {
|
|
1246
|
+
return Response.json(fail(error))
|
|
1247
|
+
}
|
|
1248
|
+
},
|
|
1249
|
+
}),
|
|
1250
|
+
'git-panel: /api/git-panel route',
|
|
1251
|
+
)
|
|
1252
|
+
})
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/** Test seams: the pure decisions, without the HTTP shell. */
|
|
1256
|
+
export const __internals = {
|
|
1257
|
+
PanelError,
|
|
1258
|
+
commitFiles,
|
|
1259
|
+
containedPath,
|
|
1260
|
+
containedPaths,
|
|
1261
|
+
lettersOf,
|
|
1262
|
+
locateRepository,
|
|
1263
|
+
parseDecorations,
|
|
1264
|
+
parseGraph,
|
|
1265
|
+
parseNumstat,
|
|
1266
|
+
parseRefList,
|
|
1267
|
+
parseStatus,
|
|
1268
|
+
perform,
|
|
1269
|
+
readCwd,
|
|
1270
|
+
runGit,
|
|
1271
|
+
}
|