@xiaoming_z/dsh-git 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,1320 @@
1
+ /**
2
+ * `@xiaoming_z/dsh-git` — Host half of the DSH git panel.
3
+ *
4
+ * `status` / `log` / `diff` / `branches` / `review` for reads, and `stage` /
5
+ * `unstage` / `hunks` / `commit` / `checkout` / `pull` / `push` for writes, all
6
+ * over a plain prefix route on `ctx.webServer`. Going through the webserver
7
+ * route table instead of a Typert Remote is what keeps this package buildable
8
+ * outside the DSH monorepo: Remotes need descriptor codegen at build time, a
9
+ * route needs nothing but the `webServer` service.
10
+ *
11
+ * The route accepts an absolute repository path per request and refuses
12
+ * anything outside `allowedRoots`, because the webserver binds loopback with no
13
+ * authentication of its own. Writes additionally require the `x-dsh-git`
14
+ * header, since this prefix sits outside the `/api` browser-trust fence.
15
+ *
16
+ * @module @xiaoming_z/dsh-git
17
+ */
18
+ import { execFile } from 'node:child_process'
19
+ import { watch } from 'node:fs'
20
+ import { realpath, stat } from 'node:fs/promises'
21
+ import { join, resolve, sep } from 'node:path'
22
+
23
+ /** Cordis plugin name. */
24
+ export const name = 'dsh-git'
25
+ /** Services this plugin resolves before its body runs. */
26
+ export const inject = ['webServer']
27
+
28
+ /** Prefix the whole route table hangs off. */
29
+ const ROUTE_PREFIX = '/git-api'
30
+ /** Git stdout can be large (a big diff); generous but bounded. */
31
+ const MAX_BUFFER = 32 * 1024 * 1024
32
+ /** Every git child gets a hard deadline so a wedged repo cannot pin the host. */
33
+ const GIT_TIMEOUT_MS = 15_000
34
+ /** Network commands get a longer one: a first fetch of a large repo is slow. */
35
+ const GIT_NETWORK_TIMEOUT_MS = 180_000
36
+ /** Cap on the git output echoed back for a network command. */
37
+ const MAX_NETWORK_OUTPUT = 8 * 1024
38
+
39
+ /**
40
+ * How a pull reconciles a diverged branch.
41
+ *
42
+ * Enumerated rather than passed through: the value reaches git's argv, and an
43
+ * open-ended string would be an option-injection hole. `ff-only` is the default
44
+ * because it is the only one that cannot rewrite history or invent a merge
45
+ * commit behind the user's back.
46
+ */
47
+ const PULL_STRATEGIES = {
48
+ 'ff-only': ['--ff-only'],
49
+ merge: ['--no-rebase'],
50
+ rebase: ['--rebase'],
51
+ }
52
+ /** Coalesce the burst of `.git` writes one git operation produces. */
53
+ const WATCH_DEBOUNCE_MS = 120
54
+ /** Newest-first commit page size when the caller does not ask for one. */
55
+ const DEFAULT_LOG_LIMIT = 200
56
+ /** Hard cap on one log page: the browser renders these, so it must stay finite. */
57
+ const MAX_LOG_LIMIT = 2_000
58
+
59
+ /** Field separator for the log pretty-format. */
60
+ const FS = '\u001f'
61
+ /** Record separator for the log pretty-format. */
62
+ const RS = '\u001e'
63
+
64
+ /**
65
+ * Run one git command in a repository.
66
+ * @param cwd - the repository working tree.
67
+ * @param args - git arguments, already split.
68
+ * @param options - `input` feeds stdin (for `git apply`); `allowExitCodes` lists
69
+ * non-zero exits that mean success for the caller.
70
+ * @returns stdout on success.
71
+ */
72
+ function git(cwd, args, options = {}) {
73
+ const { input, allowExitCodes = [], timeoutMs = GIT_TIMEOUT_MS } = options
74
+ return new Promise((resolvePromise, reject) => {
75
+ const child = execFile(
76
+ 'git',
77
+ // git quotes non-ASCII paths as octal escapes by default
78
+ // (`"a/\345\256\211..."`), which makes every diff header for a CJK
79
+ // filename unreadable. Only output quoting is affected.
80
+ ['-c', 'core.quotePath=false', ...args],
81
+ {
82
+ cwd,
83
+ maxBuffer: MAX_BUFFER,
84
+ timeout: timeoutMs,
85
+ windowsHide: true,
86
+ // Without a TTY a credential prompt cannot be answered, and git would
87
+ // sit there until the timeout kills it. Refusing immediately turns a
88
+ // hang into a message the panel can show.
89
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
90
+ },
91
+ (error, stdout, stderr) => {
92
+ if (error !== null) {
93
+ // Some commands signal a normal outcome with a non-zero exit:
94
+ // `diff --no-index` exits 1 when the inputs differ, which is the
95
+ // expected case for an untracked file.
96
+ if (typeof error.code === 'number' && allowExitCodes.includes(error.code)) {
97
+ resolvePromise(stdout)
98
+ return
99
+ }
100
+ // git does not agree with itself on which stream carries a
101
+ // diagnostic: `merge` conflicts land on stdout, `commit` with an
102
+ // empty index prints "nothing to commit" on stdout, while a bad
103
+ // revision goes to stderr. Check both before falling back to the
104
+ // useless "Command failed" text.
105
+ const detail = String(stderr ?? '').trim() || String(stdout ?? '').trim() || error.message
106
+ reject(new Error(detail))
107
+ return
108
+ }
109
+ resolvePromise(stdout)
110
+ },
111
+ )
112
+ if (input !== undefined) {
113
+ // If git exits before reading (a bad patch, an unknown option), the write
114
+ // fails with EPIPE; the exit callback owns the diagnosis.
115
+ child.stdin.on('error', () => {})
116
+ child.stdin.end(input)
117
+ }
118
+ })
119
+ }
120
+
121
+ /** Cap on a synthesised diff for an untracked file. */
122
+ const MAX_UNTRACKED_DIFF_BYTES = 400 * 1024
123
+
124
+ /**
125
+ * Split a unified diff into its file header and its hunks.
126
+ *
127
+ * `git apply` needs a patch assembled from a subset of hunks with the file
128
+ * header intact — exactly the split `git add -p` performs internally. Skipping
129
+ * an earlier hunk does not invalidate a later one's line numbers, because the
130
+ * index those numbers refer to is left untouched by the skipped hunk.
131
+ * @param text - a unified diff.
132
+ * @returns the header lines and one string per `@@` hunk.
133
+ */
134
+ export function splitHunks(text) {
135
+ const header = []
136
+ const hunks = []
137
+ let current = null
138
+ for (const line of text.split('\n')) {
139
+ if (line.startsWith('@@')) {
140
+ if (current !== null) hunks.push(current.join('\n'))
141
+ current = [line]
142
+ continue
143
+ }
144
+ if (current === null) header.push(line)
145
+ else current.push(line)
146
+ }
147
+ if (current !== null) hunks.push(current.join('\n'))
148
+ // The final `split('\n')` element is an empty string when the diff ends with
149
+ // a newline; drop trailing newlines so reassembly adds exactly one.
150
+ return { header, hunks: hunks.map((hunk) => hunk.replace(/\n+$/, '')) }
151
+ }
152
+
153
+ /**
154
+ * Parse `git diff --numstat -z`.
155
+ *
156
+ * Records are `added\tdeleted\tpath` terminated by NUL. A rename emits an empty
157
+ * path field and then the two paths as their own NUL-separated fields; a binary
158
+ * file reports `-` for both counts. Fields are split on the FIRST TWO tabs
159
+ * rather than `split('\t')` because a path may itself contain a tab.
160
+ * @param raw - the command's raw stdout.
161
+ * @returns one entry per changed path.
162
+ */
163
+ export function parseNumstat(raw) {
164
+ const files = []
165
+ const fields = raw.split('\0')
166
+ for (let i = 0; i < fields.length; i += 1) {
167
+ const record = fields[i]
168
+ if (record === '') continue
169
+ const first = record.indexOf('\t')
170
+ const second = first === -1 ? -1 : record.indexOf('\t', first + 1)
171
+ if (second === -1) continue
172
+ const added = record.slice(0, first)
173
+ const deleted = record.slice(first + 1, second)
174
+ let path = record.slice(second + 1)
175
+ let origPath = null
176
+ if (path === '') {
177
+ origPath = fields[i + 1] ?? null
178
+ path = fields[i + 2] ?? ''
179
+ i += 2
180
+ }
181
+ if (path === '') continue
182
+ files.push({
183
+ path,
184
+ origPath,
185
+ added: added === '-' ? null : Number(added),
186
+ deleted: deleted === '-' ? null : Number(deleted),
187
+ binary: added === '-',
188
+ })
189
+ }
190
+ return files
191
+ }
192
+
193
+ /**
194
+ * Parse `git diff --name-status -z` into a map keyed by the resulting path.
195
+ *
196
+ * Status and paths are all NUL-separated here (not tab), and a rename/copy
197
+ * carries two paths, so the record length depends on the status letter.
198
+ * @param raw - the command's raw stdout.
199
+ * @returns status by resulting path.
200
+ */
201
+ export function parseNameStatus(raw) {
202
+ const byPath = new Map()
203
+ const fields = raw.split('\0')
204
+ for (let i = 0; i < fields.length; i += 1) {
205
+ const status = fields[i]
206
+ if (status === '') continue
207
+ if (status[0] === 'R' || status[0] === 'C') {
208
+ const from = fields[i + 1] ?? ''
209
+ const to = fields[i + 2] ?? ''
210
+ if (to !== '') byPath.set(to, { status: status[0], score: status.slice(1), origPath: from })
211
+ i += 2
212
+ } else {
213
+ const path = fields[i + 1] ?? ''
214
+ if (path !== '') byPath.set(path, { status: status[0], score: null, origPath: null })
215
+ i += 1
216
+ }
217
+ }
218
+ return byPath
219
+ }
220
+
221
+ /**
222
+ * Parse `git status --porcelain=v2 --branch -z`.
223
+ *
224
+ * Record shapes (the path field is last and may contain spaces, so it is
225
+ * re-joined rather than indexed):
226
+ * `1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>` ordinary change
227
+ * `2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>` rename/copy
228
+ * `u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>` unmerged
229
+ * `? <path>` untracked
230
+ *
231
+ * With `-z`, records are NUL-separated and a type-`2` record is followed by one
232
+ * extra NUL-separated field holding the original path.
233
+ *
234
+ * @param raw - the command's raw stdout.
235
+ * @returns the branch header plus files grouped by index/worktree position.
236
+ */
237
+ export function parseStatusV2(raw) {
238
+ const branch = {
239
+ head: null,
240
+ oid: null,
241
+ upstream: null,
242
+ ahead: 0,
243
+ behind: 0,
244
+ detached: false,
245
+ initial: false,
246
+ }
247
+ const staged = []
248
+ const unstaged = []
249
+ const untracked = []
250
+ const conflicted = []
251
+ const fields = raw.split('\0')
252
+
253
+ for (let i = 0; i < fields.length; i += 1) {
254
+ const line = fields[i]
255
+ if (line === '') continue
256
+
257
+ if (line.startsWith('# ')) {
258
+ const [key, ...rest] = line.slice(2).split(' ')
259
+ const value = rest.join(' ')
260
+ if (key === 'branch.oid') {
261
+ branch.oid = value === '(initial)' ? null : value
262
+ branch.initial = value === '(initial)'
263
+ } else if (key === 'branch.head') {
264
+ branch.detached = value === '(detached)'
265
+ branch.head = branch.detached ? null : value
266
+ } else if (key === 'branch.upstream') {
267
+ branch.upstream = value
268
+ } else if (key === 'branch.ab') {
269
+ const [, ahead, behind] = /^\+(\d+) -(\d+)$/.exec(value) ?? []
270
+ branch.ahead = Number(ahead ?? 0)
271
+ branch.behind = Number(behind ?? 0)
272
+ }
273
+ continue
274
+ }
275
+
276
+ const kind = line[0]
277
+ if (kind === '?') {
278
+ // Untracked entries carry the same field set as every other kind. An
279
+ // absent `origPath` used to reach the browser as `undefined`, where a
280
+ // `!== null` guard happily rendered the literal text "undefined".
281
+ untracked.push({ path: line.slice(2), index: '?', worktree: '?', origPath: null })
282
+ continue
283
+ }
284
+ if (kind !== '1' && kind !== '2' && kind !== 'u') continue
285
+
286
+ const parts = line.split(' ')
287
+ // Where the path starts depends on the record kind (see the shapes above).
288
+ const pathStart = kind === '1' ? 8 : kind === '2' ? 9 : 10
289
+ const xy = parts[1] ?? '..'
290
+ const x = xy[0]
291
+ const y = xy[1]
292
+ const entry = {
293
+ path: parts.slice(pathStart).join(' '),
294
+ index: x,
295
+ worktree: y,
296
+ origPath: null,
297
+ }
298
+ if (kind === '2') {
299
+ entry.origPath = fields[i + 1] ?? null
300
+ i += 1
301
+ }
302
+
303
+ if (kind === 'u') {
304
+ conflicted.push(entry)
305
+ continue
306
+ }
307
+ if (x !== '.' && x !== ' ') staged.push(entry)
308
+ if (y !== '.' && y !== ' ') unstaged.push(entry)
309
+ }
310
+
311
+ return { branch, staged, unstaged, untracked, conflicted }
312
+ }
313
+
314
+ /**
315
+ * Parse the `%x1f`/`%x1e`-delimited log produced by {@link GitService.log}.
316
+ * @param raw - the command's raw stdout.
317
+ * @returns commits newest-first.
318
+ */
319
+ export function parseLog(raw) {
320
+ const commits = []
321
+ for (const chunk of raw.split(RS)) {
322
+ // `format:` separates entries with a newline, so every record but the first
323
+ // arrives with a leading one. Only leading newlines are stripped: the
324
+ // subject is the last field and may legitimately end in spaces.
325
+ const record = chunk.replace(/^[\r\n]+/, '')
326
+ if (record === '') continue
327
+ const [hash, parents, authorName, authorEmail, authoredAt, ...subject] = record.split(FS)
328
+ commits.push({
329
+ hash,
330
+ parents: parents === '' ? [] : parents.split(' ').filter(Boolean),
331
+ author: authorName,
332
+ email: authorEmail,
333
+ // Seconds since epoch is what git prints; the browser wants milliseconds.
334
+ at: Number(authoredAt) * 1000,
335
+ // The subject is last so an embedded separator cannot shift the fields.
336
+ subject: subject.join(FS),
337
+ })
338
+ }
339
+ return commits
340
+ }
341
+
342
+ /**
343
+ * Answer one request as JSON.
344
+ * @param res - the node response.
345
+ * @param status - HTTP status code.
346
+ * @param payload - JSON-serializable body.
347
+ */
348
+ function send(res, status, payload) {
349
+ const body = JSON.stringify(payload)
350
+ res.writeHead(status, {
351
+ 'content-type': 'application/json; charset=utf-8',
352
+ 'cache-control': 'no-store',
353
+ 'content-length': Buffer.byteLength(body),
354
+ })
355
+ res.end(body)
356
+ }
357
+
358
+ /** Header a write request must carry; see {@link assertNotCrossSite}. */
359
+ const WRITE_HEADER = 'x-dsh-git'
360
+
361
+ /** Cap on a request body. A commit message is the only body this API accepts. */
362
+ const MAX_BODY_BYTES = 64 * 1024
363
+
364
+ /**
365
+ * Read and parse a JSON request body.
366
+ * @param req - the node request.
367
+ * @returns the parsed object, or `{}` for an empty body.
368
+ */
369
+ function readJsonBody(req) {
370
+ return new Promise((resolve, reject) => {
371
+ const chunks = []
372
+ let size = 0
373
+ req.on('data', (chunk) => {
374
+ size += chunk.length
375
+ if (size > MAX_BODY_BYTES) {
376
+ reject(new Error(`request body exceeds ${MAX_BODY_BYTES} bytes`))
377
+ req.destroy()
378
+ return
379
+ }
380
+ chunks.push(chunk)
381
+ })
382
+ req.on('end', () => {
383
+ const raw = Buffer.concat(chunks).toString('utf8').trim()
384
+ if (raw === '') {
385
+ resolve({})
386
+ return
387
+ }
388
+ try {
389
+ const parsed = JSON.parse(raw)
390
+ resolve(parsed !== null && typeof parsed === 'object' ? parsed : {})
391
+ } catch {
392
+ reject(new Error('request body is not valid JSON'))
393
+ }
394
+ })
395
+ req.on('error', reject)
396
+ })
397
+ }
398
+
399
+ /**
400
+ * Validate a revision the browser wants git to act on.
401
+ *
402
+ * A ref goes straight into argv, so a leading `-` would be read as an option —
403
+ * `git checkout -f` instead of checking out a branch named `-f`. Reads tolerate
404
+ * a bad ref by falling back to HEAD; writes must refuse loudly instead.
405
+ * @param value - the caller's ref.
406
+ * @param label - field name for the error message.
407
+ * @returns the ref.
408
+ */
409
+ function requireRef(value, label) {
410
+ if (typeof value !== 'string' || value.trim() === '') throw new Error(`${label} must not be empty`)
411
+ if (value.startsWith('-')) throw new Error(`${label} must not start with "-": ${JSON.stringify(value)}`)
412
+ return value
413
+ }
414
+
415
+ /**
416
+ * Resolve the caller's declared action list.
417
+ * @param value - the request's `files` field.
418
+ * @returns a non-empty array of pathspecs.
419
+ */
420
+ function requireFiles(value) {
421
+ if (!Array.isArray(value) || value.length === 0) throw new Error('files must be a non-empty array')
422
+ for (const file of value) {
423
+ if (typeof file !== 'string' || file === '') throw new Error('every entry in files must be a non-empty string')
424
+ }
425
+ return value
426
+ }
427
+
428
+ /**
429
+ * Reject a write that did not come from the panel.
430
+ *
431
+ * The webserver binds loopback and adds no authentication of its own, and this
432
+ * route table is deliberately *outside* the `/api` browser-trust fence — which
433
+ * is what lets a plugin own it. That combination means a write endpoint is
434
+ * reachable by any page the user has open. Requiring a custom header is the
435
+ * defence: a cross-origin form or `fetch` cannot set one without triggering a
436
+ * CORS preflight, and this server answers no preflight and sends no CORS
437
+ * headers, so the browser blocks the request before it arrives.
438
+ * @param req - the node request.
439
+ */
440
+ function assertNotCrossSite(req) {
441
+ if (req.headers[WRITE_HEADER] === undefined) {
442
+ throw new Error(`refusing a write without the ${WRITE_HEADER} header (cross-site protection)`)
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Parse `git show --name-status` into a commit header plus its changed files.
448
+ * @param raw - the command's raw stdout.
449
+ * @returns the commit and its files, or null when nothing parsed.
450
+ */
451
+ export function parseCommitFiles(raw) {
452
+ const [header, ...lines] = raw.split('\n')
453
+ const [hash, author, authoredAt, ...subject] = header.split(FS)
454
+ if (hash === undefined || hash === '') return null
455
+ const files = []
456
+ for (const line of lines) {
457
+ if (line.trim() === '') continue
458
+ const [status, ...rest] = line.split('\t')
459
+ if (status === undefined || rest.length === 0) continue
460
+ // A rename/copy line carries both paths; the others carry one. Only R/C
461
+ // carry a trailing similarity score — a merge's combined status ("MM")
462
+ // would otherwise be misread as one.
463
+ const scored = status[0] === 'R' || status[0] === 'C'
464
+ files.push({
465
+ status: status[0],
466
+ score: scored && status.length > 1 ? status.slice(1) : null,
467
+ path: rest[rest.length - 1],
468
+ origPath: rest.length > 1 ? rest[0] : null,
469
+ })
470
+ }
471
+ return {
472
+ commit: { hash, author, at: Number(authoredAt) * 1000, subject: subject.join(FS) },
473
+ files,
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Per-repository git state: a small read cache plus the `.git` watcher that
479
+ * invalidates it.
480
+ *
481
+ * This is the seam that makes the panel feel like an IDE rather than a form
482
+ * submit: the browser never waits on a cold `git status`, and any git operation
483
+ * taken outside the panel (a shell commit, an IDE checkout) drops the cache
484
+ * instead of leaving a stale tree on screen.
485
+ */
486
+ class GitService {
487
+ #allowedRoots
488
+ #allowAny
489
+ #defaultRoot
490
+ #canonicalRoots = null
491
+ #cache = new Map()
492
+ #watchers = new Map()
493
+ #timers = new Map()
494
+
495
+ #knownWorkspaces
496
+
497
+ /**
498
+ * @param config - the row's config from `cordis.patch.yml`.
499
+ * @param knownWorkspaces - returns directories the harness itself considers
500
+ * workspaces; they are trusted in addition to `allowedRoots`.
501
+ */
502
+ constructor(config = {}, knownWorkspaces = () => []) {
503
+ const roots = Array.isArray(config.allowedRoots) ? config.allowedRoots : [process.cwd()]
504
+ this.#allowAny = roots.includes('*')
505
+ this.#allowedRoots = roots.filter((root) => root !== '*').map((root) => resolve(root))
506
+ this.#defaultRoot = typeof config.defaultRoot === 'string' ? resolve(config.defaultRoot) : process.cwd()
507
+ this.#knownWorkspaces = knownWorkspaces
508
+ }
509
+
510
+ /**
511
+ * Canonicalize the allowlist once, lazily.
512
+ *
513
+ * The request path is canonicalized with `realpath` before the prefix check,
514
+ * so the allowlist must be canonicalized the same way or the two never
515
+ * compare equal: on macOS `/tmp` and `/var` are symlinks into `/private`, and
516
+ * a project reached through a symlinked directory has the same problem.
517
+ * Canonicalizing lazily (rather than in the constructor) keeps `apply()`
518
+ * synchronous and tolerates a configured root that does not exist yet.
519
+ * @returns the canonical allowlist.
520
+ */
521
+ async #canonicalAllowlist() {
522
+ if (this.#canonicalRoots === null) {
523
+ this.#canonicalRoots = (async () => {
524
+ const roots = []
525
+ for (const root of this.#allowedRoots) {
526
+ // A root that cannot be canonicalized is kept as-is, so a typo in the
527
+ // config matches nothing rather than throwing at every request.
528
+ roots.push(await realpath(root).catch(() => root))
529
+ }
530
+ return roots
531
+ })()
532
+ }
533
+ return await this.#canonicalRoots
534
+ }
535
+
536
+ /**
537
+ * Canonicalize the registered workspace roots.
538
+ *
539
+ * Not memoized: a workspace can be added or removed while the panel is open,
540
+ * and the list is short enough that re-canonicalizing per request is cheap.
541
+ * @returns the canonical workspace paths.
542
+ */
543
+ async #canonicalWorkspaces() {
544
+ let paths
545
+ try {
546
+ paths = this.#knownWorkspaces()
547
+ } catch {
548
+ return []
549
+ }
550
+ if (!Array.isArray(paths)) return []
551
+ const roots = []
552
+ for (const path of paths) {
553
+ if (typeof path !== 'string' || path === '') continue
554
+ roots.push(await realpath(path).catch(() => null))
555
+ }
556
+ return roots.filter((root) => root !== null)
557
+ }
558
+
559
+ /**
560
+ * Canonicalize and vet a caller-supplied repository path.
561
+ * @param input - the `root` query parameter, or undefined to use the default.
562
+ * @returns the canonical repository root.
563
+ */
564
+ async resolveRoot(input) {
565
+ const candidate = typeof input === 'string' && input.trim() !== '' ? resolve(input) : this.#defaultRoot
566
+ const real = await realpath(candidate)
567
+ if (!(await stat(real)).isDirectory()) throw new Error(`${real} is not a directory`)
568
+ if (!this.#allowAny) {
569
+ const allowed = await this.#canonicalAllowlist()
570
+ // Directories the harness already treats as workspaces are trusted too.
571
+ // That is what makes the panel follow the session's repository without
572
+ // widening `allowedRoots` to every path on the machine: the user opted
573
+ // into these by opening them.
574
+ const workspaces = await this.#canonicalWorkspaces()
575
+ const ok = (root) => real === root || real.startsWith(root + sep)
576
+ if (!allowed.some(ok) && !workspaces.some(ok)) {
577
+ throw new Error(
578
+ `refusing to inspect ${real}: outside allowedRoots and not a known workspace ` +
579
+ '(add it to the row config, or use "*")',
580
+ )
581
+ }
582
+ }
583
+ try {
584
+ await stat(join(real, '.git'))
585
+ } catch {
586
+ throw new Error(`${real} is not a git working tree`)
587
+ }
588
+ // The watcher is set up in the background: a repository that cannot be
589
+ // watched still works, it just refreshes on demand instead of on change.
590
+ void this.#watch(real)
591
+ return real
592
+ }
593
+
594
+ /**
595
+ * The working tree's status, cached until the watcher invalidates it.
596
+ * @param root - canonical repository root.
597
+ * @returns parsed porcelain-v2 status.
598
+ */
599
+ async status(root) {
600
+ return await this.#cached(`${root}\u0000status`, async () =>
601
+ parseStatusV2(await git(root, ['status', '--porcelain=v2', '--branch', '--untracked-files=all', '-z'])),
602
+ )
603
+ }
604
+
605
+ /**
606
+ * A page of commits, newest-first, in topological order so the client's lane
607
+ * assignment does not have to cope with a parent arriving after its child.
608
+ * @param root - canonical repository root.
609
+ * @param limit - requested page size, clamped to [1, MAX_LOG_LIMIT].
610
+ * @param ref - optional branch/ref to walk instead of HEAD.
611
+ * @returns parsed commits.
612
+ */
613
+ async log(root, limit, ref) {
614
+ const size = Math.min(Math.max(1, Number(limit) || DEFAULT_LOG_LIMIT), MAX_LOG_LIMIT)
615
+ // A ref is passed as a revision argument, so a leading `-` would be read as
616
+ // an option. Reject it rather than letting a caller reach git's flag space.
617
+ const revision = typeof ref === 'string' && ref !== '' && !ref.startsWith('-') ? ref : undefined
618
+ return await this.#cached(`${root}\u0000log\u0000${size}\u0000${revision ?? ''}`, async () =>
619
+ parseLog(
620
+ await git(root, [
621
+ 'log',
622
+ `--max-count=${size}`,
623
+ '--topo-order',
624
+ `--pretty=format:%H${FS}%P${FS}%an${FS}%ae${FS}%at${FS}%s${RS}`,
625
+ ...(revision === undefined ? [] : [revision]),
626
+ ]),
627
+ ),
628
+ )
629
+ }
630
+
631
+ /**
632
+ * A unified diff. Deliberately uncached: it is the pane the user is actively
633
+ * editing against, and a stale diff is worse than a second git call.
634
+ * @param root - canonical repository root.
635
+ * @param file - optional pathspec.
636
+ * @param staged - diff the index against HEAD instead of the worktree.
637
+ * @param untracked - synthesise an all-added diff for an untracked file.
638
+ * @param since - diff one path against this marker instead of the index.
639
+ * @returns raw unified diff text.
640
+ */
641
+ async diff(root, file, staged, untracked, since) {
642
+ if (since !== undefined && since !== null && since !== '') {
643
+ const marker = requireRef(since, 'since')
644
+ const args = ['diff', '--no-color', '--no-ext-diff', marker]
645
+ if (typeof file === 'string' && file !== '') args.push('--', file)
646
+ return await git(root, args)
647
+ }
648
+ if (untracked === true) return await this.#untrackedDiff(root, file)
649
+ const args = ['diff', '--no-color', '--no-ext-diff']
650
+ if (staged === true) args.push('--cached')
651
+ if (typeof file === 'string' && file !== '') args.push('--', file)
652
+ return await git(root, args)
653
+ }
654
+
655
+ /**
656
+ * An untracked file has no diff against the index, so one is synthesised by
657
+ * diffing it against the null device. `--no-index` exits 1 whenever the
658
+ * inputs differ, which is the normal case here and is accepted.
659
+ * @param root - canonical repository root.
660
+ * @param file - path relative to the repository root.
661
+ * @returns the synthesised diff, truncated when the file is very large.
662
+ */
663
+ async #untrackedDiff(root, file) {
664
+ if (typeof file !== 'string' || file === '') throw new Error('untracked diff needs a file path')
665
+ // The path arrives from the browser, so it is confined to the repository
666
+ // before it reaches git at all.
667
+ const absolute = resolve(root, file)
668
+ if (absolute !== root && !absolute.startsWith(root + sep)) {
669
+ throw new Error(`${file} is outside the repository`)
670
+ }
671
+ const info = await stat(absolute).catch(() => null)
672
+ if (info === null || !info.isFile()) throw new Error(`${file} does not exist or is not a regular file`)
673
+ const nullDevice = process.platform === 'win32' ? 'NUL' : '/dev/null'
674
+ const text = await git(root, ['diff', '--no-color', '--no-ext-diff', '--no-index', '--', nullDevice, file], {
675
+ allowExitCodes: [1],
676
+ })
677
+ if (Buffer.byteLength(text) <= MAX_UNTRACKED_DIFF_BYTES) return text
678
+ return `${text.slice(0, MAX_UNTRACKED_DIFF_BYTES)}\n… diff truncated: the file is too large to show in full\n`
679
+ }
680
+
681
+ /**
682
+ * Stage (or unstage) a chosen subset of a file's hunks.
683
+ *
684
+ * The patch is rebuilt from the file header plus the selected hunks and piped
685
+ * to `git apply --cached`, which is how `git add -p` / `git reset -p` work.
686
+ * @param root - canonical repository root.
687
+ * @param file - path relative to the repository root.
688
+ * @param indices - zero-based hunk indices, in diff order.
689
+ * @param staged - true unstages the selected hunks from the index.
690
+ */
691
+ async applyHunks(root, file, indices, staged) {
692
+ const text = await this.diff(root, file, staged === true, false)
693
+ const { header, hunks } = splitHunks(text)
694
+ if (hunks.length === 0) throw new Error('this file has no hunks to apply')
695
+ const picked = []
696
+ for (const index of Array.isArray(indices) ? indices : []) {
697
+ if (!Number.isInteger(index) || index < 0 || index >= hunks.length) {
698
+ throw new Error(`hunk index out of range: ${JSON.stringify(index)} (this file has ${hunks.length})`)
699
+ }
700
+ picked.push(hunks[index])
701
+ }
702
+ if (picked.length === 0) throw new Error('no hunks selected')
703
+ const patch = `${[...header, ...picked].join('\n')}\n`
704
+ const args = ['apply', '--cached', '--whitespace=nowarn', '--recount']
705
+ if (staged === true) args.push('--reverse')
706
+ args.push('-')
707
+ await git(root, args, { input: patch })
708
+ this.invalidate(root)
709
+ }
710
+
711
+ /**
712
+ * Local and remote branches, split for the panel's branch tree.
713
+ * @param root - canonical repository root.
714
+ * @returns `{local, remote}`; a remote entry carries its remote and branch name.
715
+ */
716
+ async branches(root) {
717
+ return await this.#cached(`${root}\u0000branches`, async () => {
718
+ // `git branch --format` supports only `%(field)` placeholders — the
719
+ // `%x1f` escape is a pretty-format feature and would come through
720
+ // literally here. A literal TAB is the safe separator instead:
721
+ // check-ref-format forbids every ASCII control character in a refname.
722
+ const raw = await git(root, [
723
+ 'branch',
724
+ '--all',
725
+ `--format=%(HEAD)\t%(refname)\t%(refname:short)\t%(upstream:short)\t%(objectname:short)`,
726
+ ])
727
+ const local = []
728
+ const remote = []
729
+ for (const line of raw.split('\n')) {
730
+ if (line === '') continue
731
+ const [head, refname, short, upstream, oid] = line.split('\t')
732
+ if (refname.startsWith('refs/heads/')) {
733
+ local.push({ name: short, current: head.trim() === '*', upstream: upstream === '' ? null : upstream, oid })
734
+ } else if (refname.startsWith('refs/remotes/')) {
735
+ // Derive everything from the full refname, never from
736
+ // `%(refname:short)`: for the symbolic `refs/remotes/origin/HEAD`
737
+ // git shortens to plain `origin`, so a short-name check would let it
738
+ // through as a bogus branch named after the remote.
739
+ if (refname.endsWith('/HEAD')) continue
740
+ const rest = refname.slice('refs/remotes/'.length)
741
+ const slash = rest.indexOf('/')
742
+ remote.push({
743
+ name: rest,
744
+ remote: slash === -1 ? '' : rest.slice(0, slash),
745
+ branch: slash === -1 ? rest : rest.slice(slash + 1),
746
+ oid,
747
+ })
748
+ }
749
+ }
750
+ return { local, remote, head: local.find((branch) => branch.current)?.name ?? null }
751
+ })
752
+ }
753
+
754
+ /**
755
+ * One commit's header plus the files it touched.
756
+ * @param root - canonical repository root.
757
+ * @param hash - the commit to inspect.
758
+ * @returns the parsed commit detail, or null when the hash resolves to nothing.
759
+ */
760
+ async commitDetail(root, hash) {
761
+ return await this.#cached(`${root}\u0000commit\u0000${hash}`, async () => {
762
+ const raw = await git(root, [
763
+ 'show',
764
+ '--name-status',
765
+ '--no-color',
766
+ '--format=%H' + FS + '%an' + FS + '%at' + FS + '%s',
767
+ hash,
768
+ ])
769
+ return parseCommitFiles(raw)
770
+ })
771
+ }
772
+
773
+ /**
774
+ * The patch a single commit applied, optionally limited to one path.
775
+ * @param root - canonical repository root.
776
+ * @param hash - the commit to show.
777
+ * @param file - optional pathspec.
778
+ * @returns raw unified diff text.
779
+ */
780
+ async show(root, hash, file) {
781
+ const args = ['show', '--no-color', '--no-ext-diff', '--format=', hash]
782
+ if (typeof file === 'string' && file !== '') args.push('--', file)
783
+ return await git(root, args)
784
+ }
785
+
786
+ /**
787
+ * Stage paths.
788
+ * @param root - canonical repository root.
789
+ * @param files - pathspecs to stage.
790
+ */
791
+ async stage(root, files) {
792
+ // `-A` so a staged deletion is recorded too, which plain `git add` handles
793
+ // only from git 2.x with `--all` explicitly.
794
+ await git(root, ['add', '-A', '--', ...files])
795
+ this.invalidate(root)
796
+ }
797
+
798
+ /**
799
+ * Unstage paths, leaving the worktree untouched.
800
+ * @param root - canonical repository root.
801
+ * @param files - pathspecs to unstage.
802
+ */
803
+ async unstage(root, files) {
804
+ // `reset` rather than `restore --staged`: it also drops a newly added file
805
+ // out of the index, which `restore` refuses because the path is not in HEAD.
806
+ await git(root, ['reset', '-q', 'HEAD', '--', ...files])
807
+ this.invalidate(root)
808
+ }
809
+
810
+ /**
811
+ * Commit what is staged.
812
+ * @param root - canonical repository root.
813
+ * @param message - the commit message.
814
+ * @returns the new commit's short hash.
815
+ */
816
+ async createCommit(root, message) {
817
+ await git(root, ['commit', '-m', message])
818
+ this.invalidate(root)
819
+ return (await git(root, ['rev-parse', '--short', 'HEAD'])).trim()
820
+ }
821
+
822
+ /**
823
+ * Check out a ref, optionally creating a branch first.
824
+ *
825
+ * A branch name is validated by git itself (`check-ref-format --branch`)
826
+ * rather than by a hand-rolled pattern: the rules are long, they have changed
827
+ * between versions, and git is the authority on them.
828
+ * @param root - canonical repository root.
829
+ * @param ref - the branch or commit-ish to check out, or the start point.
830
+ * @param create - when given, create this branch at `ref` and switch to it.
831
+ */
832
+ async checkout(root, ref, create) {
833
+ const target = requireRef(ref, 'ref')
834
+ if (create === undefined || create === null || create === '') {
835
+ await git(root, ['checkout', target])
836
+ } else {
837
+ const name = requireRef(create, 'branch name')
838
+ // Non-zero exit means git rejected the name; its own stderr is the message.
839
+ await git(root, ['check-ref-format', '--branch', name])
840
+ await git(root, ['checkout', '-b', name, target])
841
+ }
842
+ this.invalidate(root)
843
+ }
844
+
845
+ /**
846
+ * Fetch and integrate the current branch's upstream.
847
+ *
848
+ * Reports whether HEAD actually moved rather than parsing git's message: the
849
+ * wording is localized and has changed between versions, while the two hashes
850
+ * are unambiguous.
851
+ * @param root - canonical repository root.
852
+ * @param strategy - one of {@link PULL_STRATEGIES}.
853
+ * @returns `{changed, before, after, output}`.
854
+ */
855
+ async pull(root, strategy) {
856
+ const args = PULL_STRATEGIES[strategy]
857
+ if (args === undefined) {
858
+ throw new Error(
859
+ `unknown pull strategy ${JSON.stringify(strategy)} (expected one of ${Object.keys(PULL_STRATEGIES).join(', ')})`,
860
+ )
861
+ }
862
+ const head = async () =>
863
+ await git(root, ['rev-parse', 'HEAD'], { allowExitCodes: [128] })
864
+ .then((value) => value.trim())
865
+ .catch(() => '')
866
+ const before = await head()
867
+ const output = await git(root, ['pull', ...args], { timeoutMs: GIT_NETWORK_TIMEOUT_MS })
868
+ const after = await head()
869
+ this.invalidate(root)
870
+ return {
871
+ changed: before !== after,
872
+ before: before.slice(0, 7),
873
+ after: after.slice(0, 7),
874
+ output:
875
+ output.length > MAX_NETWORK_OUTPUT ? `${output.slice(0, MAX_NETWORK_OUTPUT)}\n… output truncated` : output,
876
+ }
877
+ }
878
+
879
+ /**
880
+ * Everything that changed since a marker commit, committed or not.
881
+ *
882
+ * The marker is compared against the WORKING TREE rather than HEAD, so an
883
+ * agent that left changes uncommitted is not hidden by a review view that
884
+ * only looks at history.
885
+ * @param root - canonical repository root.
886
+ * @param since - the marker commit-ish.
887
+ * @returns commits since the marker, per-file stats, and totals.
888
+ */
889
+ async review(root, since) {
890
+ const marker = requireRef(since, 'since')
891
+ // A rebase can delete the marker commit; resolving first turns that into a
892
+ // clear error instead of an empty-looking review.
893
+ await git(root, ['rev-parse', '--verify', `${marker}^{commit}`])
894
+ const [logRaw, nameRaw, numRaw, headRaw] = await Promise.all([
895
+ git(root, [
896
+ 'log',
897
+ '--topo-order',
898
+ '--max-count=500',
899
+ `--pretty=format:%H${FS}%P${FS}%an${FS}%ae${FS}%at${FS}%s${RS}`,
900
+ `${marker}..HEAD`,
901
+ ]),
902
+ git(root, ['diff', '--name-status', '-z', '--find-renames', marker]),
903
+ git(root, ['diff', '--numstat', '-z', '--find-renames', marker]),
904
+ git(root, ['rev-parse', '--short', 'HEAD']),
905
+ ])
906
+ const statuses = parseNameStatus(nameRaw)
907
+ const files = parseNumstat(numRaw).map((entry) => ({
908
+ ...entry,
909
+ status: statuses.get(entry.path)?.status ?? null,
910
+ }))
911
+ const totals = files.reduce(
912
+ (sum, file) => ({
913
+ files: sum.files + 1,
914
+ added: sum.added + (file.added ?? 0),
915
+ deleted: sum.deleted + (file.deleted ?? 0),
916
+ binary: sum.binary + (file.binary ? 1 : 0),
917
+ }),
918
+ { files: 0, added: 0, deleted: 0, binary: 0 },
919
+ )
920
+ return { marker, head: headRaw.trim(), commits: parseLog(logRaw), files, totals }
921
+ }
922
+
923
+ /**
924
+ * Push the current branch, publishing it first when it has no upstream.
925
+ *
926
+ * Reports the upstream's hash before and after rather than parsing git's
927
+ * wording, for the same reason as {@link pull}: messages are localized while
928
+ * a hash is not. Before the first push of a branch there is no upstream at
929
+ * all, which reads as an empty "before" and therefore as a change.
930
+ * @param root - canonical repository root.
931
+ * @param setUpstream - create/point the upstream as part of the push.
932
+ * @returns `{branch, remote, published, changed, before, after, output}`.
933
+ */
934
+ async push(root, setUpstream) {
935
+ const branch = (await git(root, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()
936
+ if (branch === 'HEAD') throw new Error('cannot push: HEAD is detached')
937
+ const args = ['push']
938
+ let remote = null
939
+ if (setUpstream === true) {
940
+ remote = await this.#publishRemote(root)
941
+ args.push('--set-upstream', remote, branch)
942
+ }
943
+ const before = await this.#upstreamHash(root)
944
+ const output = await git(root, args, { timeoutMs: GIT_NETWORK_TIMEOUT_MS })
945
+ const after = await this.#upstreamHash(root)
946
+ this.invalidate(root)
947
+ return {
948
+ branch,
949
+ remote,
950
+ published: setUpstream === true,
951
+ changed: before !== after,
952
+ before: before.slice(0, 7),
953
+ after: after.slice(0, 7),
954
+ output:
955
+ output.length > MAX_NETWORK_OUTPUT
956
+ ? `${output.slice(0, MAX_NETWORK_OUTPUT)}\n… output truncated`
957
+ : output,
958
+ }
959
+ }
960
+
961
+ /**
962
+ * The hash the branch's upstream points at, or an empty string when the
963
+ * branch has no upstream yet.
964
+ * @param root - canonical repository root.
965
+ * @returns the hash, or ''.
966
+ */
967
+ async #upstreamHash(root) {
968
+ try {
969
+ return (await git(root, ['rev-parse', '@{upstream}'])).trim()
970
+ } catch {
971
+ return ''
972
+ }
973
+ }
974
+
975
+ /**
976
+ * Choose the remote to publish a branch to.
977
+ *
978
+ * `origin` is the overwhelming convention, and a single remote is
979
+ * unambiguous. Anything else is refused instead of guessed: pushing to the
980
+ * wrong remote is not something to discover afterwards.
981
+ * @param root - canonical repository root.
982
+ * @returns the remote name.
983
+ */
984
+ async #publishRemote(root) {
985
+ const remotes = (await git(root, ['remote']))
986
+ .split('\n')
987
+ .map((name) => name.trim())
988
+ .filter((name) => name !== '')
989
+ if (remotes.includes('origin')) return 'origin'
990
+ if (remotes.length === 1) return remotes[0]
991
+ if (remotes.length === 0) throw new Error('no remote to publish to')
992
+ throw new Error(`several remotes and none is "origin"; publish from a terminal (${remotes.join(', ')})`)
993
+ }
994
+
995
+ /**
996
+ * Drop every cached entry for one repository.
997
+ *
998
+ * Public because a write through this API must not wait for the filesystem
999
+ * watcher's debounce to expire: the panel refreshes immediately after its own
1000
+ * mutation and would otherwise read the state it just replaced.
1001
+ * @param root - canonical repository root.
1002
+ */
1003
+ invalidate(root) {
1004
+ this.#invalidate(root)
1005
+ }
1006
+
1007
+ /**
1008
+ * Read-through cache keyed by root and query shape.
1009
+ * @param key - cache key.
1010
+ * @param produce - the git call to run on a miss.
1011
+ * @returns the cached or freshly produced value.
1012
+ */
1013
+ async #cached(key, produce) {
1014
+ const hit = this.#cache.get(key)
1015
+ if (hit !== undefined) return hit
1016
+ const value = await produce()
1017
+ this.#cache.set(key, value)
1018
+ return value
1019
+ }
1020
+
1021
+ /**
1022
+ * Drop every cached entry for one repository.
1023
+ * @param root - canonical repository root.
1024
+ */
1025
+ #invalidate(root) {
1026
+ const prefix = `${root}\u0000`
1027
+ for (const key of [...this.#cache.keys()]) {
1028
+ if (key.startsWith(prefix)) this.#cache.delete(key)
1029
+ }
1030
+ }
1031
+
1032
+ /**
1033
+ * Where a repository keeps its git directory.
1034
+ *
1035
+ * `<root>/.git` is a directory in a plain repository, but it is a FILE in a
1036
+ * linked worktree (`git worktree add`) and in a submodule — a one-line
1037
+ * pointer to the real directory (`…/.git/worktrees/<name>`). Watching that
1038
+ * file means watching a file that never changes: a checkout in the worktree
1039
+ * rewrites `<gitdir>/HEAD`, far away, and the cache would keep serving the
1040
+ * old branch forever.
1041
+ *
1042
+ * git itself knows the answer, and knows it for every layout —
1043
+ * `--separate-git-dir`, `core.worktree`, submodules, worktrees.
1044
+ * @param root - canonical repository root.
1045
+ * @returns the git directory, or the conventional path when git cannot say.
1046
+ */
1047
+ async #gitDir(root) {
1048
+ try {
1049
+ const out = await git(root, ['rev-parse', '--absolute-git-dir'])
1050
+ const dir = out.trim()
1051
+ if (dir !== '') return dir
1052
+ } catch {
1053
+ /* older git, or an exotic layout: fall back to the conventional path */
1054
+ }
1055
+ return join(root, '.git')
1056
+ }
1057
+
1058
+ /**
1059
+ * Attach a best-effort watcher to a repository's git directory.
1060
+ *
1061
+ * Non-recursive on purpose: `HEAD`, `index` and the mtime of `refs` all sit
1062
+ * at this level, which is what a commit or checkout touches. Failing to
1063
+ * watch is not fatal — it degrades the panel to refresh-on-demand, which is
1064
+ * why the catch is silent.
1065
+ * @param root - canonical repository root.
1066
+ */
1067
+ async #watch(root) {
1068
+ if (this.#watchers.has(root)) return
1069
+ try {
1070
+ const dir = await this.#gitDir(root)
1071
+ this.#watchers.set(
1072
+ root,
1073
+ watch(dir, { persistent: false }, () => this.#scheduleInvalidate(root)),
1074
+ )
1075
+ } catch {
1076
+ /* not watchable (permissions, exotic filesystem): fall back to manual refresh */
1077
+ }
1078
+ }
1079
+
1080
+ /**
1081
+ * Debounce the write burst one git operation emits.
1082
+ * @param root - canonical repository root.
1083
+ */
1084
+ #scheduleInvalidate(root) {
1085
+ const pending = this.#timers.get(root)
1086
+ if (pending !== undefined) clearTimeout(pending)
1087
+ this.#timers.set(
1088
+ root,
1089
+ setTimeout(() => {
1090
+ this.#timers.delete(root)
1091
+ this.#invalidate(root)
1092
+ }, WATCH_DEBOUNCE_MS),
1093
+ )
1094
+ }
1095
+
1096
+ /** Release every watcher, timer and cache entry. */
1097
+ dispose() {
1098
+ for (const timer of this.#timers.values()) clearTimeout(timer)
1099
+ this.#timers.clear()
1100
+ for (const watcher of this.#watchers.values()) {
1101
+ try {
1102
+ watcher.close()
1103
+ } catch {
1104
+ /* already closed */
1105
+ }
1106
+ }
1107
+ this.#watchers.clear()
1108
+ this.#cache.clear()
1109
+ }
1110
+ }
1111
+
1112
+ /**
1113
+ * Dispatch one request under {@link ROUTE_PREFIX}.
1114
+ * @param ctx - the plugin context, for logging.
1115
+ * @param service - the git state holder.
1116
+ * @param req - the node request.
1117
+ * @param res - the node response.
1118
+ */
1119
+ async function handle(ctx, service, req, res) {
1120
+ const method = req.method ?? 'GET'
1121
+ if (method !== 'GET' && method !== 'POST') {
1122
+ send(res, 405, { ok: false, error: `method ${method} not allowed` })
1123
+ return
1124
+ }
1125
+
1126
+ let url
1127
+ try {
1128
+ url = new URL(req.url ?? '/', 'http://localhost')
1129
+ } catch {
1130
+ send(res, 400, { ok: false, error: 'malformed request url' })
1131
+ return
1132
+ }
1133
+
1134
+ const route = url.pathname.slice(ROUTE_PREFIX.length) || '/'
1135
+ const params = url.searchParams
1136
+
1137
+ try {
1138
+ if (method === 'GET') {
1139
+ switch (route) {
1140
+ case '/health':
1141
+ send(res, 200, { ok: true, data: { service: name, pid: process.pid, writes: true } })
1142
+ return
1143
+
1144
+ case '/status': {
1145
+ const root = await service.resolveRoot(params.get('root') ?? undefined)
1146
+ send(res, 200, { ok: true, data: { root, ...(await service.status(root)) } })
1147
+ return
1148
+ }
1149
+
1150
+ case '/log': {
1151
+ const root = await service.resolveRoot(params.get('root') ?? undefined)
1152
+ const commits = await service.log(root, params.get('limit'), params.get('ref') ?? undefined)
1153
+ send(res, 200, { ok: true, data: { root, ref: params.get('ref') ?? null, commits } })
1154
+ return
1155
+ }
1156
+
1157
+ case '/diff': {
1158
+ const root = await service.resolveRoot(params.get('root') ?? undefined)
1159
+ const staged = params.get('staged') === '1'
1160
+ const untracked = params.get('untracked') === '1'
1161
+ const since = params.get('since')
1162
+ const text = await service.diff(root, params.get('file'), staged, untracked, since)
1163
+ send(res, 200, {
1164
+ ok: true,
1165
+ data: { root, file: params.get('file'), staged, untracked, since: since ?? null, text },
1166
+ })
1167
+ return
1168
+ }
1169
+
1170
+ case '/branches': {
1171
+ const root = await service.resolveRoot(params.get('root') ?? undefined)
1172
+ send(res, 200, { ok: true, data: { root, ...(await service.branches(root)) } })
1173
+ return
1174
+ }
1175
+
1176
+ case '/review': {
1177
+ const root = await service.resolveRoot(params.get('root') ?? undefined)
1178
+ const since = params.get('since')
1179
+ if (since === null || since === '') throw new Error('review needs a since marker')
1180
+ send(res, 200, { ok: true, data: { root, ...(await service.review(root, since)) } })
1181
+ return
1182
+ }
1183
+
1184
+ case '/commit': {
1185
+ const root = await service.resolveRoot(params.get('root') ?? undefined)
1186
+ const detail = await service.commitDetail(root, params.get('hash') ?? 'HEAD')
1187
+ if (detail === null) {
1188
+ send(res, 404, { ok: false, error: `no commit ${JSON.stringify(params.get('hash'))}` })
1189
+ return
1190
+ }
1191
+ send(res, 200, { ok: true, data: { root, ...detail } })
1192
+ return
1193
+ }
1194
+
1195
+ case '/show': {
1196
+ const root = await service.resolveRoot(params.get('root') ?? undefined)
1197
+ const text = await service.show(root, params.get('hash') ?? 'HEAD', params.get('file'))
1198
+ send(res, 200, { ok: true, data: { root, text } })
1199
+ return
1200
+ }
1201
+
1202
+ default:
1203
+ send(res, 404, { ok: false, error: `unknown git-api route ${JSON.stringify(route)}` })
1204
+ return
1205
+ }
1206
+ }
1207
+
1208
+ // ── writes ────────────────────────────────────────────────────────────────
1209
+ // Every write is refused without the custom header, so a page the user has
1210
+ // open cannot drive git through this loopback route table.
1211
+ assertNotCrossSite(req)
1212
+ const body = await readJsonBody(req)
1213
+
1214
+ switch (route) {
1215
+ case '/stage': {
1216
+ const root = await service.resolveRoot(typeof body.root === 'string' ? body.root : undefined)
1217
+ const files = requireFiles(body.files)
1218
+ await service.stage(root, files)
1219
+ send(res, 200, { ok: true, data: { root, staged: files } })
1220
+ return
1221
+ }
1222
+
1223
+ case '/unstage': {
1224
+ const root = await service.resolveRoot(typeof body.root === 'string' ? body.root : undefined)
1225
+ const files = requireFiles(body.files)
1226
+ await service.unstage(root, files)
1227
+ send(res, 200, { ok: true, data: { root, unstaged: files } })
1228
+ return
1229
+ }
1230
+
1231
+ case '/checkout': {
1232
+ const root = await service.resolveRoot(typeof body.root === 'string' ? body.root : undefined)
1233
+ const ref = requireRef(body.ref, 'ref')
1234
+ const create = body.create === undefined || body.create === null ? undefined : body.create
1235
+ await service.checkout(root, ref, create)
1236
+ send(res, 200, { ok: true, data: { root, ref, create: create ?? null } })
1237
+ return
1238
+ }
1239
+
1240
+ case '/pull': {
1241
+ const root = await service.resolveRoot(typeof body.root === 'string' ? body.root : undefined)
1242
+ const strategy = body.strategy === undefined || body.strategy === null ? 'ff-only' : body.strategy
1243
+ const result = await service.pull(root, strategy)
1244
+ send(res, 200, { ok: true, data: { root, strategy, ...result } })
1245
+ return
1246
+ }
1247
+
1248
+ case '/push': {
1249
+ const root = await service.resolveRoot(typeof body.root === 'string' ? body.root : undefined)
1250
+ const setUpstream = body.setUpstream === true
1251
+ const result = await service.push(root, setUpstream)
1252
+ send(res, 200, { ok: true, data: { root, ...result } })
1253
+ return
1254
+ }
1255
+
1256
+ case '/hunks': {
1257
+ const root = await service.resolveRoot(typeof body.root === 'string' ? body.root : undefined)
1258
+ const file = typeof body.file === 'string' ? body.file : ''
1259
+ if (file === '') throw new Error('hunks needs a file')
1260
+ const staged = body.staged === true
1261
+ await service.applyHunks(root, file, body.hunks, staged)
1262
+ send(res, 200, { ok: true, data: { root, file, staged, hunks: body.hunks } })
1263
+ return
1264
+ }
1265
+
1266
+ case '/commit': {
1267
+ const root = await service.resolveRoot(typeof body.root === 'string' ? body.root : undefined)
1268
+ const message = typeof body.message === 'string' ? body.message.trim() : ''
1269
+ if (message === '') throw new Error('commit message must not be empty')
1270
+ const hash = await service.createCommit(root, message)
1271
+ send(res, 200, { ok: true, data: { root, hash } })
1272
+ return
1273
+ }
1274
+
1275
+ default:
1276
+ send(res, 404, { ok: false, error: `unknown git-api write route ${JSON.stringify(route)}` })
1277
+ return
1278
+ }
1279
+ } catch (error) {
1280
+ // Caller-caused problems (bad path, not a repo, outside allowedRoots, an
1281
+ // empty message, a rejected cross-site write) are 400s with the git
1282
+ // diagnostic attached; the panel shows that text verbatim.
1283
+ const message = error instanceof Error ? error.message : String(error)
1284
+ ctx.logger?.warn?.(`dsh-git: ${method} ${route} failed: ${message}`)
1285
+ send(res, 400, { ok: false, error: message })
1286
+ }
1287
+ }
1288
+
1289
+ /**
1290
+ * Plugin body: mount one prefix route over a {@link GitService}.
1291
+ * @param ctx - the plugin context.
1292
+ * @param config - the row's config from `cordis.patch.yml`.
1293
+ */
1294
+ export function apply(ctx, config) {
1295
+ // Resolved lazily and defensively: the workspace registry is optional, and a
1296
+ // deployment without it simply falls back to `allowedRoots`.
1297
+ const knownWorkspaces = () => {
1298
+ // Registered as `workspaceRegistry` — that is how
1299
+ // `dsh-api-workspace-controller` reaches it. Deliberately no fallback to a
1300
+ // guessed second name: an unverified alias would make it ambiguous which
1301
+ // one is actually in play when the allowlist behaves unexpectedly.
1302
+ const registry = ctx.get?.('workspaceRegistry')
1303
+ if (registry === undefined || registry === null || typeof registry.list !== 'function') return []
1304
+ return registry.list().map((workspace) => workspace?.path)
1305
+ }
1306
+ const service = new GitService(config ?? {}, knownWorkspaces)
1307
+ ctx.effect(() => () => service.dispose(), 'dsh-git: service lifetime')
1308
+ ctx.effect(
1309
+ () =>
1310
+ ctx.webServer.register({
1311
+ kind: 'prefix',
1312
+ path: ROUTE_PREFIX,
1313
+ handler: (req, res) => {
1314
+ void handle(ctx, service, req, res)
1315
+ },
1316
+ }),
1317
+ 'dsh-git: route table',
1318
+ )
1319
+ ctx.logger?.info?.(`dsh-git: mounted ${ROUTE_PREFIX}`)
1320
+ }