@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/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
+ }