dsh-ide-git 0.4.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/src/index.js ADDED
@@ -0,0 +1,1249 @@
1
+ /**
2
+ * dsh-ide-git — host half (plain JavaScript, no build step).
3
+ *
4
+ * One HTTP surface backs the browser panel:
5
+ *
6
+ * POST /dsh-ide-git/api/<method> -> { ok: true, data } | { ok: false, error }
7
+ *
8
+ * Every method is a thin, argv-array wrapper around the local `git` binary
9
+ * (never a shell string), executed with the working directory the client sent
10
+ * (the session scope cwd). Read methods are cheap; write methods are explicit
11
+ * and the destructive ones require an explicit `confirm: true` from the caller.
12
+ *
13
+ * Trust fence: the routes are mounted on DSH's own web server, so they are
14
+ * same-origin by construction. A request is served only when the Host header
15
+ * names a loopback authority or the browser marks the request as same-origin
16
+ * (Sec-Fetch-Site) — a cross-site page cannot forge that header, which is the
17
+ * same posture dsh-better-sidebar's own /sidebar/api routes take.
18
+ */
19
+ import { spawn } from 'node:child_process'
20
+ import { createHash } from 'node:crypto'
21
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs'
22
+ import path from 'node:path'
23
+
24
+ export const name = 'dsh-ide-git'
25
+ export const inject = ['webServer']
26
+
27
+ const ROUTE_PREFIX = '/dsh-ide-git/api'
28
+ const MAX_OUTPUT_BYTES = 8 * 1024 * 1024
29
+ const MAX_DIFF_CHARS = 400 * 1024
30
+ const DEFAULT_TIMEOUT_MS = 30_000
31
+ const NETWORK_TIMEOUT_MS = 180_000
32
+ const RECORD_SEP = '\u001e'
33
+ const FIELD_SEP = '\u001f'
34
+
35
+ /* ============================== errors ============================== */
36
+
37
+ class PanelError extends Error {
38
+ constructor(code, message, status = 400) {
39
+ super(message)
40
+ this.code = code
41
+ this.status = status
42
+ }
43
+ }
44
+
45
+ function badRequest(message) {
46
+ return new PanelError('bad-request', message, 400)
47
+ }
48
+
49
+ /* ============================== argv git ============================== */
50
+
51
+ function gitBinary() {
52
+ const override = process.env.DSH_IDE_GIT_BIN
53
+ return override !== undefined && override.trim() !== '' ? override.trim() : 'git'
54
+ }
55
+
56
+ /**
57
+ * Run git with an argv array (no shell). Resolves for ANY exit code so callers
58
+ * can decide whether a non-zero status is data (e.g. `git diff --quiet`) or an
59
+ * error; `git()` below is the throwing form.
60
+ */
61
+ function runGit(cwd, args, options = {}) {
62
+ const timeoutMs = options.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : options.timeoutMs
63
+ return new Promise((resolve, reject) => {
64
+ let child
65
+ try {
66
+ child = spawn(gitBinary(), args, { cwd, windowsHide: true, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' } })
67
+ } catch (error) {
68
+ reject(new PanelError('git-missing', 'cannot start git: ' + messageOf(error), 500))
69
+ return
70
+ }
71
+ const out = []
72
+ const err = []
73
+ let outBytes = 0
74
+ let errBytes = 0
75
+ let settled = false
76
+ const timer = setTimeout(() => {
77
+ if (settled) return
78
+ settled = true
79
+ child.kill('SIGKILL')
80
+ reject(new PanelError('git-timeout', 'git ' + args[0] + ' timed out after ' + timeoutMs + 'ms', 504))
81
+ }, timeoutMs)
82
+ child.stdout.on('data', (chunk) => {
83
+ outBytes += chunk.length
84
+ if (outBytes <= MAX_OUTPUT_BYTES) out.push(chunk)
85
+ })
86
+ child.stderr.on('data', (chunk) => {
87
+ errBytes += chunk.length
88
+ if (errBytes <= MAX_OUTPUT_BYTES) err.push(chunk)
89
+ })
90
+ child.on('error', (error) => {
91
+ if (settled) return
92
+ settled = true
93
+ clearTimeout(timer)
94
+ reject(new PanelError('git-missing', 'cannot run git: ' + messageOf(error), 500))
95
+ })
96
+ child.on('close', (code, signal) => {
97
+ if (settled) return
98
+ settled = true
99
+ clearTimeout(timer)
100
+ resolve({
101
+ code: code === null ? -1 : code,
102
+ signal: signal === null ? undefined : signal,
103
+ stdout: Buffer.concat(out).toString('utf8'),
104
+ stderr: Buffer.concat(err).toString('utf8'),
105
+ truncated: outBytes > MAX_OUTPUT_BYTES,
106
+ })
107
+ })
108
+ })
109
+ }
110
+
111
+ async function git(cwd, args, options = {}) {
112
+ const result = await runGit(cwd, args, options)
113
+ if (result.code !== 0) {
114
+ const detail = result.stderr.trim() === '' ? result.stdout.trim() : result.stderr.trim()
115
+ throw new PanelError('git-failed', 'git ' + args.join(' ') + ' failed: ' + (detail === '' ? 'exit ' + result.code : detail), 409)
116
+ }
117
+ return result.stdout
118
+ }
119
+
120
+ function messageOf(error) {
121
+ return error instanceof Error ? error.message : String(error)
122
+ }
123
+
124
+ /* ============================== payload helpers ============================== */
125
+
126
+ function requireAbsolute(value, label) {
127
+ if (typeof value !== 'string' || value.trim() === '') throw badRequest(label + ' is required')
128
+ if (!path.isAbsolute(value)) throw badRequest(label + ' must be an absolute path')
129
+ return path.resolve(value)
130
+ }
131
+
132
+ /** A ref/branch/tag name: no whitespace, no leading dash, no control characters. */
133
+ function requireRef(value, label) {
134
+ if (typeof value !== 'string' || value.trim() === '') throw badRequest(label + ' is required')
135
+ const name = value.trim()
136
+ if (name.startsWith('-')) throw badRequest(label + ' must not start with "-"')
137
+ if (/[\u0000-\u001f\u007f\s]/.test(name)) throw badRequest(label + ' contains invalid characters')
138
+ return name
139
+ }
140
+
141
+ function optionalRevs(value, label) {
142
+ if (value === undefined || value === null) return []
143
+ if (!Array.isArray(value)) throw badRequest(label + ' must be an array')
144
+ return value.map((entry) => requireRef(entry, label + ' entry'))
145
+ }
146
+
147
+ function requireMessage(value) {
148
+ if (typeof value !== 'string' || value.trim() === '') throw badRequest('message is required')
149
+ return value
150
+ }
151
+
152
+ function cwdOf(payload) {
153
+ return requireAbsolute(payload === undefined ? undefined : payload.cwd, 'cwd')
154
+ }
155
+
156
+ /* ============================== git reads ============================== */
157
+
158
+ async function repoRootOf(cwd, payload) {
159
+ if (payload !== undefined && payload.repoRoot !== undefined) return requireAbsolute(payload.repoRoot, 'repoRoot')
160
+ const result = await runGit(cwd, ['rev-parse', '--show-toplevel'])
161
+ if (result.code !== 0) {
162
+ // A workspace that is not a repository is a normal state, not a failure:
163
+ // the client answers it with a repo picker instead of an error banner.
164
+ throw new PanelError('not-a-repo', 'not a git repository: ' + cwd, 409)
165
+ }
166
+ const top = result.stdout.trim()
167
+ return top === '' ? cwd : top
168
+ }
169
+
170
+ async function currentBranchOf(root) {
171
+ const name = (await git(root, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()
172
+ return name
173
+ }
174
+
175
+ async function upstreamOf(root, branch) {
176
+ const result = await runGit(root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', branch + '@{u}'])
177
+ if (result.code !== 0) return null
178
+ const name = result.stdout.trim()
179
+ return name === '' ? null : name
180
+ }
181
+
182
+ async function aheadBehindOf(root, upstream, branch) {
183
+ if (upstream === null) return { ahead: 0, behind: 0 }
184
+ const result = await runGit(root, ['rev-list', '--left-right', '--count', upstream + '...' + branch])
185
+ if (result.code !== 0) return { ahead: 0, behind: 0 }
186
+ const parts = result.stdout.trim().split(/\s+/)
187
+ const behind = Number.parseInt(parts[0], 10)
188
+ const ahead = Number.parseInt(parts[1], 10)
189
+ return { ahead: Number.isFinite(ahead) ? ahead : 0, behind: Number.isFinite(behind) ? behind : 0 }
190
+ }
191
+
192
+ /** Parse `git status --porcelain=v1 -z` (NUL separated, rename pairs follow). */
193
+ function parsePorcelain(stdout) {
194
+ const records = stdout.split('\u0000')
195
+ const entries = []
196
+ for (let index = 0; index < records.length; index += 1) {
197
+ const record = records[index]
198
+ if (record.length < 4) continue
199
+ const x = record[0]
200
+ const y = record[1]
201
+ const filePath = record.slice(3)
202
+ let origPath
203
+ if (x === 'R' || x === 'C' || y === 'R' || y === 'C') {
204
+ const next = records[index + 1]
205
+ if (next !== undefined && next !== '') {
206
+ origPath = next
207
+ index += 1
208
+ }
209
+ }
210
+ entries.push({ x, y, path: filePath, origPath })
211
+ }
212
+ return entries
213
+ }
214
+
215
+ function classify(entries) {
216
+ const staged = []
217
+ const unstaged = []
218
+ const untracked = []
219
+ const conflicted = []
220
+ for (const entry of entries) {
221
+ const pair = entry.x + entry.y
222
+ if (pair === '??') { untracked.push(entry); continue }
223
+ if (pair === '!!') continue
224
+ if (pair === 'DD' || pair === 'AU' || pair === 'UD' || pair === 'UA' || pair === 'DU' || pair === 'AA' || pair === 'UU') {
225
+ conflicted.push(entry)
226
+ continue
227
+ }
228
+ if (entry.x !== ' ' && entry.x !== '?') staged.push(entry)
229
+ if (entry.y !== ' ' && entry.y !== '?') unstaged.push(entry)
230
+ }
231
+ return { staged, unstaged, untracked, conflicted }
232
+ }
233
+
234
+ /** An ignored tree can be enormous, and this list is informational only. */
235
+ const MAX_IGNORED_ENTRIES = 500
236
+
237
+ /**
238
+ * Ignored paths, asked for on their own. `git status -uall --ignored` expands
239
+ * every file inside an ignored directory — node_modules alone would flood the
240
+ * panel — while `ls-files --directory` reports the directory as a single entry.
241
+ */
242
+ async function ignoredEntriesOf(root) {
243
+ const result = await runGit(root, ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'])
244
+ if (result.code !== 0) return { entries: [], truncated: false }
245
+ const paths = result.stdout.split('\u0000').filter((entry) => entry !== '')
246
+ const kept = paths.slice(0, MAX_IGNORED_ENTRIES)
247
+ return {
248
+ entries: kept.map((filePath) => ({ index: '!', worktree: '!', path: filePath })),
249
+ truncated: paths.length > kept.length,
250
+ }
251
+ }
252
+
253
+ /** `git diff --numstat -z` -> Map(path -> { additions, deletions, binary }). */
254
+ async function numstatOf(root, args) {
255
+ const result = await runGit(root, ['diff', '--numstat', '-z', ...args])
256
+ const map = new Map()
257
+ if (result.code !== 0) return map
258
+ const records = result.stdout.split('\u0000')
259
+ for (let index = 0; index < records.length; index += 1) {
260
+ const record = records[index]
261
+ if (record === '') continue
262
+ const firstTab = record.indexOf('\t')
263
+ const secondTab = record.indexOf('\t', firstTab + 1)
264
+ if (firstTab < 0 || secondTab < 0) continue
265
+ const addRaw = record.slice(0, firstTab)
266
+ const delRaw = record.slice(firstTab + 1, secondTab)
267
+ let filePath = record.slice(secondTab + 1)
268
+ if (filePath === '') {
269
+ filePath = records[index + 1] === undefined ? '' : records[index + 1]
270
+ index += 1
271
+ const renamed = records[index + 1]
272
+ if (renamed !== undefined && renamed !== '') index += 1
273
+ }
274
+ const binary = addRaw === '-' || delRaw === '-'
275
+ map.set(filePath, {
276
+ additions: binary ? 0 : Number.parseInt(addRaw, 10) || 0,
277
+ deletions: binary ? 0 : Number.parseInt(delRaw, 10) || 0,
278
+ binary,
279
+ })
280
+ }
281
+ return map
282
+ }
283
+
284
+ function decorate(entries, numstat) {
285
+ return entries.map((entry) => {
286
+ const stat = numstat.get(entry.path)
287
+ return {
288
+ path: entry.path,
289
+ origPath: entry.origPath,
290
+ index: entry.x,
291
+ worktree: entry.y,
292
+ additions: stat === undefined ? 0 : stat.additions,
293
+ deletions: stat === undefined ? 0 : stat.deletions,
294
+ binary: stat === undefined ? false : stat.binary,
295
+ }
296
+ })
297
+ }
298
+
299
+ /** Parse the FIELD_SEP/RECORD_SEP pretty format used by `log`/`show`. */
300
+ function parseLog(stdout) {
301
+ const commits = []
302
+ for (const record of stdout.split(RECORD_SEP)) {
303
+ const trimmed = record.replace(/^\n+/, '')
304
+ if (trimmed.trim() === '') continue
305
+ const parts = trimmed.split(FIELD_SEP)
306
+ if (parts.length < 8) continue
307
+ commits.push({
308
+ hash: parts[0],
309
+ shortHash: parts[1],
310
+ parents: parts[2].trim() === '' ? [] : parts[2].trim().split(' '),
311
+ author: parts[3],
312
+ email: parts[4],
313
+ date: parts[5],
314
+ subject: parts[6],
315
+ refs: parts[7].trim() === '' ? [] : parts[7].split(',').map((ref) => ref.trim()).filter((ref) => ref !== ''),
316
+ })
317
+ }
318
+ return commits
319
+ }
320
+
321
+ const LOG_FORMAT = '%H' + FIELD_SEP + '%h' + FIELD_SEP + '%P' + FIELD_SEP + '%an' + FIELD_SEP + '%ae' + FIELD_SEP + '%aI' + FIELD_SEP + '%s' + FIELD_SEP + '%D' + RECORD_SEP
322
+
323
+ async function stashCountOf(root) {
324
+ const result = await runGit(root, ['rev-list', '--walk-reflogs', '--count', 'refs/stash'])
325
+ if (result.code !== 0) return 0
326
+ const count = Number.parseInt(result.stdout.trim(), 10)
327
+ return Number.isFinite(count) ? count : 0
328
+ }
329
+
330
+ /** Same directory, tolerating a trailing slash and macOS /var vs /private/var. */
331
+ function sameDirectory(left, right) {
332
+ const one = path.resolve(String(left))
333
+ const two = path.resolve(String(right))
334
+ if (one === two) return true
335
+ try { return realpathSync(one) === realpathSync(two) } catch { return false }
336
+ }
337
+
338
+ async function worktreesOf(root) {
339
+ const result = await runGit(root, ['worktree', 'list', '--porcelain'])
340
+ if (result.code !== 0) return []
341
+ const trees = []
342
+ let current = null
343
+ for (const line of result.stdout.split('\n')) {
344
+ if (line.startsWith('worktree ')) {
345
+ if (current !== null) trees.push(current)
346
+ current = { path: line.slice('worktree '.length), branch: null, head: null, detached: false, bare: false }
347
+ continue
348
+ }
349
+ if (current === null) continue
350
+ if (line.startsWith('HEAD ')) current.head = line.slice('HEAD '.length)
351
+ else if (line.startsWith('branch ')) current.branch = line.slice('branch '.length).replace('refs/heads/', '')
352
+ else if (line === 'detached') current.detached = true
353
+ else if (line === 'bare') current.bare = true
354
+ }
355
+ if (current !== null) trees.push(current)
356
+ return trees
357
+ }
358
+
359
+ /* ============================== safety ============================== */
360
+
361
+ /**
362
+ * Write operations on one repository run one at a time. The client greys its
363
+ * actions out while a request is in flight, but a stale tab, a second window or
364
+ * a double click could still start two writers, and git dislikes concurrent
365
+ * index writers — so the host serialises writes per repository.
366
+ */
367
+ const repoQueues = new Map()
368
+
369
+ function withRepoLock(key, work) {
370
+ const previous = repoQueues.get(key) === undefined ? Promise.resolve() : repoQueues.get(key)
371
+ const next = previous.then(work, work)
372
+ const settled = next.then(() => undefined, () => undefined)
373
+ repoQueues.set(key, settled)
374
+ settled.then(() => { if (repoQueues.get(key) === settled) repoQueues.delete(key) })
375
+ return next
376
+ }
377
+
378
+ const WRITE_METHODS = new Set([
379
+ 'stage', 'unstage', 'discard', 'commit', 'checkout', 'branchCreate', 'branchRename',
380
+ 'branchDelete', 'merge', 'rebase', 'cherryPick', 'revert', 'reset', 'fetch', 'pull',
381
+ 'push', 'stashPush', 'stashApply', 'stashDrop', 'tagCreate', 'tagDelete', 'undoApply',
382
+ ])
383
+
384
+ function lockKeyOf(payload) {
385
+ if (typeof payload.repoRoot === 'string' && payload.repoRoot !== '') return payload.repoRoot
386
+ if (typeof payload.cwd === 'string' && payload.cwd !== '') return payload.cwd
387
+ return 'global'
388
+ }
389
+
390
+ /* Deleting is a real delete — but the host remembers how to bring it back for a
391
+ while, so the panel can offer an undo. Entries are per repository, newest
392
+ last, capped and time-limited. */
393
+ const UNDO_LIMIT = 20
394
+ const UNDO_TTL_MS = 30 * 60 * 1000
395
+ const undoStacks = new Map()
396
+ let undoSeq = 0
397
+
398
+ /** Branches whose deletion is never a routine action. */
399
+ const PROTECTED_BRANCHES = new Set(['main', 'master', 'trunk'])
400
+
401
+ function undoEntriesOf(root) {
402
+ const stored = undoStacks.get(root) === undefined ? [] : undoStacks.get(root)
403
+ const alive = stored.filter((entry) => Date.now() - entry.at < UNDO_TTL_MS)
404
+ if (alive.length !== stored.length) undoStacks.set(root, alive)
405
+ return alive
406
+ }
407
+
408
+ function pushUndo(root, entry) {
409
+ const list = undoEntriesOf(root)
410
+ undoSeq += 1
411
+ const record = Object.assign({ id: 'u' + undoSeq.toString(36) + Date.now().toString(36), at: Date.now() }, entry)
412
+ list.push(record)
413
+ while (list.length > UNDO_LIMIT) list.shift()
414
+ undoStacks.set(root, list)
415
+ return { id: record.id, kind: record.kind, label: record.label }
416
+ }
417
+
418
+ /* Undoing a discard means writing the old bytes back, so the host has to keep
419
+ them: a checkout/clean is the one moment they still exist. Snapshots stay
420
+ deliberately conservative — regular files only, capped in count and in total
421
+ size — and anything that cannot be reproduced faithfully (a symlink, a
422
+ special file, a huge tree) yields NO undo handle at all rather than a promise
423
+ the host cannot keep. */
424
+ const UNDO_MAX_FILES = 400
425
+ const UNDO_MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024
426
+
427
+ function hashOf(data) {
428
+ return createHash('sha1').update(data).digest('hex')
429
+ }
430
+
431
+ /** Repo-relative POSIX path of a target, or null when it escapes the repo. */
432
+ function relInside(root, target) {
433
+ const rel = path.relative(root, target)
434
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return null
435
+ return rel.split(path.sep).join('/')
436
+ }
437
+
438
+ /**
439
+ * Read every regular file the given paths cover (each path may be a file or a
440
+ * directory). Returns null when the snapshot would not be faithful, which makes
441
+ * the caller skip the undo handle instead of offering one it cannot honour.
442
+ */
443
+ function snapshotForUndo(root, paths) {
444
+ const files = []
445
+ let bytes = 0
446
+ const collect = (target) => {
447
+ const rel = relInside(root, target)
448
+ if (rel === null) return true
449
+ let stat
450
+ try {
451
+ stat = lstatSync(target)
452
+ } catch (error) {
453
+ // Already gone ('D' in the working tree) is itself a state worth undoing
454
+ // to, so record the absence. Any other failure is not ours to guess at.
455
+ if (error.code === 'ENOENT') { files.push({ rel: rel, data: null }); return true }
456
+ return false
457
+ }
458
+ if (stat.isSymbolicLink()) return false
459
+ if (stat.isDirectory()) {
460
+ if (path.basename(target) === '.git') return true
461
+ let names
462
+ try { names = readdirSync(target) } catch { return false }
463
+ for (const name of names) if (!collect(path.join(target, name))) return false
464
+ return true
465
+ }
466
+ if (!stat.isFile()) return false
467
+ if (files.length >= UNDO_MAX_FILES) return false
468
+ let data
469
+ try { data = readFileSync(target) } catch { return false }
470
+ bytes += data.length
471
+ if (bytes > UNDO_MAX_SNAPSHOT_BYTES) return false
472
+ files.push({ rel: rel, data: data })
473
+ return true
474
+ }
475
+ for (const entry of paths) if (!collect(path.resolve(root, entry))) return null
476
+ return files
477
+ }
478
+
479
+ /**
480
+ * The shape the snapshot targets have right now: a content hash, or null when
481
+ * the path is absent. The undo compares against this before writing anything,
482
+ * so content typed after the discard is never silently overwritten.
483
+ */
484
+ function snapshotState(root, files) {
485
+ return files.map((file) => {
486
+ try {
487
+ return { rel: file.rel, hash: hashOf(readFileSync(path.join(root, file.rel))) }
488
+ } catch {
489
+ return { rel: file.rel, hash: null }
490
+ }
491
+ })
492
+ }
493
+
494
+ /** .git markers that mean a multi-step operation is still in progress. */
495
+ async function operationOf(root) {
496
+ const result = await runGit(root, ['rev-parse', '--absolute-git-dir'])
497
+ if (result.code !== 0) return null
498
+ const gitDir = result.stdout.trim()
499
+ if (gitDir === '') return null
500
+ const markers = [
501
+ ['merge', 'MERGE_HEAD'],
502
+ ['rebase', 'rebase-merge'],
503
+ ['rebase', 'rebase-apply'],
504
+ ['cherry-pick', 'CHERRY_PICK_HEAD'],
505
+ ['revert', 'REVERT_HEAD'],
506
+ ['bisect', 'BISECT_LOG'],
507
+ ]
508
+ for (const marker of markers) {
509
+ if (existsSync(path.join(gitDir, marker[1]))) return marker[0]
510
+ }
511
+ return null
512
+ }
513
+
514
+ /* ============================== methods ============================== */
515
+
516
+ async function summary(payload) {
517
+ const cwd = cwdOf(payload)
518
+ const root = await repoRootOf(cwd, payload)
519
+ const branch = await currentBranchOf(root)
520
+ const detached = branch === 'HEAD'
521
+ const upstream = detached ? null : await upstreamOf(root, branch)
522
+ const tracking = upstream === null ? { ahead: 0, behind: 0 } : await aheadBehindOf(root, upstream, branch)
523
+ const statusOut = await git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])
524
+ const groups = classify(parsePorcelain(statusOut))
525
+ const ignored = payload.ignored === true ? await ignoredEntriesOf(root) : { entries: [], truncated: false }
526
+ const unstagedStat = await numstatOf(root, [])
527
+ const stagedStat = await numstatOf(root, ['--cached'])
528
+ return {
529
+ repoRoot: root,
530
+ cwd,
531
+ branch,
532
+ detached,
533
+ upstream,
534
+ ahead: tracking.ahead,
535
+ behind: tracking.behind,
536
+ changes: {
537
+ staged: decorate(groups.staged, stagedStat),
538
+ unstaged: decorate(groups.unstaged, unstagedStat),
539
+ untracked: decorate(groups.untracked, new Map()),
540
+ conflicted: decorate(groups.conflicted, new Map()),
541
+ ignored: ignored.entries,
542
+ ignoredTruncated: ignored.truncated,
543
+ },
544
+ stashCount: await stashCountOf(root),
545
+ worktrees: await worktreesOf(root),
546
+ operation: await operationOf(root),
547
+ }
548
+ }
549
+
550
+ async function branches(payload) {
551
+ const cwd = cwdOf(payload)
552
+ const root = await repoRootOf(cwd, payload)
553
+ const branch = await currentBranchOf(root)
554
+ const format = ['%(refname)', '%(refname:short)', '%(objectname)', '%(objectname:short)', '%(HEAD)', '%(upstream:short)', '%(committerdate:iso-strict)', '%(contents:subject)', '%(symref)'].join(FIELD_SEP)
555
+ const out = await git(root, ['for-each-ref', '--format=' + format + RECORD_SEP, 'refs/heads', 'refs/remotes', 'refs/tags'])
556
+ const local = []
557
+ const remote = []
558
+ const tags = []
559
+ const worktrees = await worktreesOf(root)
560
+ const busy = new Map()
561
+ for (const tree of worktrees) {
562
+ if (tree.branch === null || tree.branch === '') continue
563
+ // `git worktree list` includes the checkout this repository lives in, so
564
+ // without this the CURRENT branch was flagged as occupied by its own working
565
+ // tree (reported on v0.3.11: main showed the marker while checked out here).
566
+ // Only a SECOND working tree holding a branch blocks checking it out here.
567
+ if (sameDirectory(tree.path, root)) continue
568
+ busy.set(tree.branch, tree.path)
569
+ }
570
+ for (const record of out.split(RECORD_SEP)) {
571
+ const trimmed = record.replace(/^\n+/, '')
572
+ if (trimmed.trim() === '') continue
573
+ const parts = trimmed.split(FIELD_SEP)
574
+ if (parts.length < 9) continue
575
+ const entry = {
576
+ name: parts[1],
577
+ hash: parts[2],
578
+ shortHash: parts[3],
579
+ head: parts[4] === '*',
580
+ upstream: parts[5] === '' ? null : parts[5],
581
+ date: parts[6],
582
+ subject: parts[7],
583
+ worktree: busy.get(parts[1]) === undefined ? null : busy.get(parts[1]),
584
+ ahead: 0,
585
+ behind: 0,
586
+ }
587
+ if (parts[0].startsWith('refs/heads/')) local.push(entry)
588
+ else if (parts[0].startsWith('refs/remotes/')) remote.push(entry)
589
+ else if (parts[0].startsWith('refs/tags/')) tags.push({ name: entry.name, hash: entry.hash, shortHash: entry.shortHash, date: entry.date, subject: entry.subject })
590
+ }
591
+ for (const entry of local) {
592
+ if (entry.upstream === null) continue
593
+ const tracking = await aheadBehindOf(root, entry.upstream, entry.name)
594
+ entry.ahead = tracking.ahead
595
+ entry.behind = tracking.behind
596
+ }
597
+ local.sort((a, b) => (a.head === b.head ? a.name.localeCompare(b.name) : a.head ? -1 : 1))
598
+ remote.sort((a, b) => a.name.localeCompare(b.name))
599
+ tags.sort((a, b) => a.name.localeCompare(b.name))
600
+ const remotesOut = await runGit(root, ['remote'])
601
+ const remotes = remotesOut.code === 0 ? remotesOut.stdout.split('\n').map((line) => line.trim()).filter((line) => line !== '') : []
602
+ return { repoRoot: root, branch, local, remote, tags, remotes }
603
+ }
604
+
605
+ async function log(payload) {
606
+ const cwd = cwdOf(payload)
607
+ const root = await repoRootOf(cwd, payload)
608
+ const limit = clampInt(payload.limit, 1, 500, 100)
609
+ const skip = clampInt(payload.skip, 0, 1_000_000, 0)
610
+ const args = ['log', '--max-count=' + (limit + 1), '--skip=' + skip, '--date-order', '--pretty=format:' + LOG_FORMAT]
611
+ if (typeof payload.rev === 'string' && payload.rev.trim() !== '') args.push(requireRef(payload.rev, 'rev'))
612
+ if (typeof payload.path === 'string' && payload.path.trim() !== '') args.push('--', payload.path)
613
+ const out = await git(root, args)
614
+ const all = parseLog(out)
615
+ const hasMore = all.length > limit
616
+ return { repoRoot: root, commits: hasMore ? all.slice(0, limit) : all, hasMore, skip, limit }
617
+ }
618
+
619
+ async function commitDetail(payload) {
620
+ const cwd = cwdOf(payload)
621
+ const root = await repoRootOf(cwd, payload)
622
+ const hash = requireRef(payload.hash, 'hash')
623
+ const metaFormat = ['%H', '%h', '%P', '%an', '%ae', '%aI', '%s', '%b', '%D'].join(FIELD_SEP)
624
+ const metaOut = await git(root, ['show', '-s', '--format=' + metaFormat, hash])
625
+ const parts = metaOut.trim().split(FIELD_SEP)
626
+ const parents = parts[2] === undefined || parts[2].trim() === '' ? [] : parts[2].trim().split(' ')
627
+ const numstatOut = await git(root, ['show', '--numstat', '-z', '--format=', '-m', '--first-parent', hash])
628
+ const files = []
629
+ const records = numstatOut.split('\u0000')
630
+ for (let index = 0; index < records.length; index += 1) {
631
+ const record = records[index]
632
+ if (record === '') continue
633
+ const firstTab = record.indexOf('\t')
634
+ const secondTab = record.indexOf('\t', firstTab + 1)
635
+ if (firstTab < 0 || secondTab < 0) continue
636
+ let filePath = record.slice(secondTab + 1)
637
+ let origPath
638
+ if (filePath === '') {
639
+ origPath = records[index + 1]
640
+ filePath = records[index + 2]
641
+ index += 2
642
+ }
643
+ const addRaw = record.slice(0, firstTab)
644
+ const delRaw = record.slice(firstTab + 1, secondTab)
645
+ const binary = addRaw === '-' || delRaw === '-'
646
+ files.push({
647
+ path: filePath === undefined ? '' : filePath,
648
+ origPath: origPath === undefined || origPath === '' ? undefined : origPath,
649
+ additions: binary ? 0 : Number.parseInt(addRaw, 10) || 0,
650
+ deletions: binary ? 0 : Number.parseInt(delRaw, 10) || 0,
651
+ binary,
652
+ })
653
+ }
654
+ return {
655
+ hash: parts[0],
656
+ shortHash: parts[1],
657
+ parents,
658
+ author: parts[3],
659
+ email: parts[4],
660
+ date: parts[5],
661
+ subject: parts[6],
662
+ body: parts[7] === undefined ? '' : parts[7],
663
+ refs: parts[8] === undefined || parts[8].trim() === '' ? [] : parts[8].split(',').map((ref) => ref.trim()).filter((ref) => ref !== ''),
664
+ files,
665
+ }
666
+ }
667
+
668
+ async function diff(payload) {
669
+ const cwd = cwdOf(payload)
670
+ const root = await repoRootOf(cwd, payload)
671
+ const args = ['diff', '--no-color', '--no-ext-diff', '-U3']
672
+ if (payload.staged === true) args.push('--cached')
673
+ if (typeof payload.hash === 'string' && payload.hash.trim() !== '') {
674
+ args.length = 0
675
+ args.push('show', '--no-color', '--no-ext-diff', '-U3', '--format=', requireRef(payload.hash, 'hash'))
676
+ }
677
+ if (typeof payload.path === 'string' && payload.path.trim() !== '') args.push('--', requireRelativePath(payload.path))
678
+ const out = await git(root, args)
679
+ const truncated = out.length > MAX_DIFF_CHARS
680
+ return { patch: truncated ? out.slice(0, MAX_DIFF_CHARS) : out, truncated }
681
+ }
682
+
683
+ function requireRelativePath(value) {
684
+ if (typeof value !== 'string' || value.trim() === '') throw badRequest('path is required')
685
+ const trimmed = value.trim()
686
+ if (path.isAbsolute(trimmed)) return trimmed
687
+ if (trimmed.split('/').includes('..')) throw badRequest('path must stay inside the repository')
688
+ return trimmed
689
+ }
690
+
691
+ async function stage(payload) {
692
+ const cwd = cwdOf(payload)
693
+ const root = await repoRootOf(cwd, payload)
694
+ const paths = optionalPaths(payload, 'paths')
695
+ await git(root, ['add', '-A', '--', ...paths])
696
+ return { staged: paths.length }
697
+ }
698
+
699
+ async function unstage(payload) {
700
+ const cwd = cwdOf(payload)
701
+ const root = await repoRootOf(cwd, payload)
702
+ const paths = optionalPaths(payload, 'paths')
703
+ const result = await runGit(root, ['reset', '-q', 'HEAD', '--', ...paths])
704
+ if (result.code !== 0) await git(root, ['rm', '--cached', '-r', '-q', '--', ...paths])
705
+ return { unstaged: paths.length }
706
+ }
707
+
708
+ async function discard(payload) {
709
+ const cwd = cwdOf(payload)
710
+ const root = await repoRootOf(cwd, payload)
711
+ // '.' would discard EVERYTHING in the repository in one go; the panel always
712
+ // discards explicit paths, so the root itself is not a valid target here.
713
+ const paths = optionalPaths(payload, 'paths').filter((entry) => entry !== '.' && entry !== './')
714
+ if (paths.length === 0) throw badRequest('paths is required')
715
+ const untracked = payload.untracked === true
716
+ if (untracked && payload.confirm !== true) throw badRequest('discarding untracked files requires confirm: true')
717
+ // Last chance to keep the bytes: the checkout/clean below is what destroys them.
718
+ const files = snapshotForUndo(root, paths)
719
+ if (untracked) await git(root, ['clean', '-f', '-d', '--', ...paths])
720
+ else await git(root, ['checkout', '--', ...paths])
721
+ // A snapshot the host cannot reproduce faithfully means no handle at all — the
722
+ // panel says so instead of showing an undo that would fail later.
723
+ if (files === null) return { discarded: paths.length, untracked: untracked, undoBlocked: true }
724
+ const label = paths.length === 1 ? paths[0] : paths.length + ' paths'
725
+ const undo = pushUndo(root, { kind: 'discard', label: label, files: files, after: snapshotState(root, files) })
726
+ return { discarded: paths.length, untracked: untracked, undo: undo }
727
+ }
728
+
729
+ async function commit(payload) {
730
+ const cwd = cwdOf(payload)
731
+ const root = await repoRootOf(cwd, payload)
732
+ const message = requireMessage(payload.message)
733
+ const paths = (payload.paths === undefined || payload.paths === null) ? [] : optionalPaths(payload, 'paths')
734
+ if (paths.length > 0) await git(root, ['add', '-A', '--', ...paths])
735
+ const args = ['commit', '-m', message]
736
+ if (payload.amend === true) args.push('--amend')
737
+ if (payload.signoff === true) args.push('--signoff')
738
+ const out = await git(root, args)
739
+ const result = { output: out.trim() }
740
+ if (payload.push === true) {
741
+ // Commit-and-push is one gesture in the panel, so the commit is published in
742
+ // the same request. A failed push must not hide the commit that did land.
743
+ if (payload.confirm !== true) throw badRequest('pushing requires confirm: true')
744
+ const pushed = await runGit(root, ['push'])
745
+ result.pushed = pushed.code === 0
746
+ if (pushed.code !== 0) result.pushError = (pushed.stderr.trim() || pushed.stdout.trim() || 'push failed')
747
+ }
748
+ return result
749
+ }
750
+
751
+ async function checkout(payload) {
752
+ const cwd = cwdOf(payload)
753
+ const root = await repoRootOf(cwd, payload)
754
+ if (payload.create === true) {
755
+ const name = requireRef(payload.branch, 'branch')
756
+ const start = typeof payload.startPoint === 'string' && payload.startPoint.trim() !== '' ? requireRef(payload.startPoint, 'startPoint') : undefined
757
+ const args = ['checkout', '-b', name]
758
+ if (start !== undefined) args.push(start)
759
+ await git(root, args)
760
+ return { branch: name, created: true }
761
+ }
762
+ const target = requireRef(payload.branch, 'branch')
763
+ await git(root, ['checkout', target])
764
+ return { branch: target, created: false }
765
+ }
766
+
767
+ async function branchCreate(payload) {
768
+ const cwd = cwdOf(payload)
769
+ const root = await repoRootOf(cwd, payload)
770
+ const name = requireRef(payload.name, 'name')
771
+ const args = ['branch', name]
772
+ if (typeof payload.startPoint === 'string' && payload.startPoint.trim() !== '') args.push(requireRef(payload.startPoint, 'startPoint'))
773
+ await git(root, args)
774
+ return { branch: name }
775
+ }
776
+
777
+ async function branchRename(payload) {
778
+ const cwd = cwdOf(payload)
779
+ const root = await repoRootOf(cwd, payload)
780
+ const from = requireRef(payload.from, 'from')
781
+ const to = requireRef(payload.to, 'to')
782
+ await git(root, ['branch', '-m', from, to])
783
+ return { from, to }
784
+ }
785
+
786
+ async function branchDelete(payload) {
787
+ const cwd = cwdOf(payload)
788
+ const root = await repoRootOf(cwd, payload)
789
+ const name = requireRef(payload.name, 'name')
790
+ const force = payload.force === true
791
+ if (force && payload.confirm !== true) throw badRequest('force delete requires confirm: true')
792
+ const current = await currentBranchOf(root)
793
+ if (name === current) throw new PanelError('protected-branch', 'refusing to delete the checked-out branch "' + name + '"', 409)
794
+ if (PROTECTED_BRANCHES.has(name) && payload.confirm !== true) throw badRequest('deleting "' + name + '" requires confirm: true')
795
+ const head = (await git(root, ['rev-parse', '--verify', 'refs/heads/' + name])).trim()
796
+ // -d (the default) refuses to drop an unmerged branch: git is the guard here.
797
+ await git(root, ['branch', force ? '-D' : '-d', name])
798
+ const undo = pushUndo(root, { kind: 'branch-delete', label: name, name: name, hash: head })
799
+ return { branch: name, force, hash: head, undo: undo }
800
+ }
801
+
802
+ async function merge(payload) {
803
+ const cwd = cwdOf(payload)
804
+ const root = await repoRootOf(cwd, payload)
805
+ const branch = requireRef(payload.branch, 'branch')
806
+ const args = ['merge', '--no-edit']
807
+ if (payload.ffOnly === true) args.push('--ff-only')
808
+ if (payload.noFf === true) args.push('--no-ff')
809
+ if (payload.squash === true) args.push('--squash')
810
+ args.push(branch)
811
+ const result = await runGit(root, args)
812
+ const combined = (result.stdout + '\n' + result.stderr).trim()
813
+ const conflicted = /CONFLICT|Automatic merge failed/.test(combined)
814
+ if (result.code !== 0 && !conflicted) throw new PanelError('git-failed', combined === '' ? 'merge failed' : combined, 409)
815
+ return { branch, conflicted, output: combined }
816
+ }
817
+
818
+ async function rebase(payload) {
819
+ const cwd = cwdOf(payload)
820
+ const root = await repoRootOf(cwd, payload)
821
+ const onto = requireRef(payload.onto, 'onto')
822
+ const result = await runGit(root, ['rebase', onto])
823
+ const combined = (result.stdout + '\n' + result.stderr).trim()
824
+ const conflicted = /CONFLICT|could not apply/.test(combined)
825
+ if (result.code !== 0 && !conflicted) throw new PanelError('git-failed', combined === '' ? 'rebase failed' : combined, 409)
826
+ return { onto, conflicted, output: combined }
827
+ }
828
+
829
+ async function cherryPick(payload) {
830
+ const cwd = cwdOf(payload)
831
+ const root = await repoRootOf(cwd, payload)
832
+ const hash = requireRef(payload.hash, 'hash')
833
+ const result = await runGit(root, ['cherry-pick', hash])
834
+ const combined = (result.stdout + '\n' + result.stderr).trim()
835
+ const conflicted = /CONFLICT/.test(combined)
836
+ if (result.code !== 0 && !conflicted) throw new PanelError('git-failed', combined === '' ? 'cherry-pick failed' : combined, 409)
837
+ return { hash, conflicted, output: combined }
838
+ }
839
+
840
+ async function revert(payload) {
841
+ const cwd = cwdOf(payload)
842
+ const root = await repoRootOf(cwd, payload)
843
+ const hash = requireRef(payload.hash, 'hash')
844
+ const result = await runGit(root, ['revert', '--no-edit', hash])
845
+ const combined = (result.stdout + '\n' + result.stderr).trim()
846
+ const conflicted = /CONFLICT/.test(combined)
847
+ if (result.code !== 0 && !conflicted) throw new PanelError('git-failed', combined === '' ? 'revert failed' : combined, 409)
848
+ return { hash, conflicted, output: combined }
849
+ }
850
+
851
+ async function reset(payload) {
852
+ const cwd = cwdOf(payload)
853
+ const root = await repoRootOf(cwd, payload)
854
+ const hash = requireRef(payload.hash, 'hash')
855
+ const mode = payload.mode === 'soft' || payload.mode === 'hard' ? payload.mode : 'mixed'
856
+ if (mode === 'hard' && payload.confirm !== true) throw badRequest('hard reset requires confirm: true')
857
+ await git(root, ['reset', '--' + mode, hash])
858
+ return { hash, mode }
859
+ }
860
+
861
+ async function fetch(payload) {
862
+ const cwd = cwdOf(payload)
863
+ const root = await repoRootOf(cwd, payload)
864
+ const args = ['fetch']
865
+ if (typeof payload.remote === 'string' && payload.remote.trim() !== '') args.push(requireRef(payload.remote, 'remote'))
866
+ if (payload.prune !== false) args.push('--prune')
867
+ args.push('--tags')
868
+ const out = await git(root, args, { timeoutMs: NETWORK_TIMEOUT_MS })
869
+ return { output: (out + '\n').trim() }
870
+ }
871
+
872
+ async function pull(payload) {
873
+ const cwd = cwdOf(payload)
874
+ const root = await repoRootOf(cwd, payload)
875
+ const mode = payload.mode === 'rebase' || payload.mode === 'merge' ? payload.mode : 'ff-only'
876
+ const args = ['pull']
877
+ if (mode === 'ff-only') args.push('--ff-only')
878
+ if (mode === 'rebase') args.push('--rebase')
879
+ if (mode === 'merge') args.push('--no-rebase')
880
+ if (typeof payload.remote === 'string' && payload.remote.trim() !== '') args.push(requireRef(payload.remote, 'remote'))
881
+ if (typeof payload.branch === 'string' && payload.branch.trim() !== '') args.push(requireRef(payload.branch, 'branch'))
882
+ const out = await git(root, args, { timeoutMs: NETWORK_TIMEOUT_MS })
883
+ return { mode, output: out.trim() }
884
+ }
885
+
886
+ async function push(payload) {
887
+ const cwd = cwdOf(payload)
888
+ const root = await repoRootOf(cwd, payload)
889
+ if (payload.confirm !== true) throw badRequest('push requires confirm: true')
890
+ const args = ['push']
891
+ if (payload.setUpstream === true) args.push('--set-upstream')
892
+ if (typeof payload.remote === 'string' && payload.remote.trim() !== '') args.push(requireRef(payload.remote, 'remote'))
893
+ if (typeof payload.branch === 'string' && payload.branch.trim() !== '') args.push(requireRef(payload.branch, 'branch'))
894
+ const out = await git(root, args, { timeoutMs: NETWORK_TIMEOUT_MS })
895
+ return { output: (out + '\n').trim() }
896
+ }
897
+
898
+ async function stashList(payload) {
899
+ const cwd = cwdOf(payload)
900
+ const root = await repoRootOf(cwd, payload)
901
+ const out = await git(root, ['stash', 'list', '--pretty=format:%gd' + FIELD_SEP + '%gs' + FIELD_SEP + '%aI' + RECORD_SEP])
902
+ const stashes = []
903
+ for (const record of out.split(RECORD_SEP)) {
904
+ const trimmed = record.replace(/^\n+/, '')
905
+ if (trimmed.trim() === '') continue
906
+ const parts = trimmed.split(FIELD_SEP)
907
+ stashes.push({ ref: parts[0], subject: parts[1] === undefined ? '' : parts[1], date: parts[2] === undefined ? '' : parts[2] })
908
+ }
909
+ return { stashes }
910
+ }
911
+
912
+ async function stashPush(payload) {
913
+ const cwd = cwdOf(payload)
914
+ const root = await repoRootOf(cwd, payload)
915
+ const args = ['stash', 'push']
916
+ if (payload.includeUntracked === true) args.push('--include-untracked')
917
+ if (typeof payload.message === 'string' && payload.message.trim() !== '') args.push('-m', payload.message)
918
+ const out = await git(root, args)
919
+ return { output: out.trim() }
920
+ }
921
+
922
+ async function stashApply(payload) {
923
+ const cwd = cwdOf(payload)
924
+ const root = await repoRootOf(cwd, payload)
925
+ const ref = typeof payload.ref === 'string' && payload.ref.trim() !== '' ? requireRef(payload.ref, 'ref') : 'stash@{0}'
926
+ const args = ['stash', payload.pop === true ? 'pop' : 'apply', ref]
927
+ const result = await runGit(root, args)
928
+ const combined = (result.stdout + '\n' + result.stderr).trim()
929
+ const conflicted = /CONFLICT/.test(combined)
930
+ if (result.code !== 0 && !conflicted) throw new PanelError('git-failed', combined === '' ? 'stash apply failed' : combined, 409)
931
+ return { ref, conflicted, output: combined }
932
+ }
933
+
934
+ async function stashDrop(payload) {
935
+ const cwd = cwdOf(payload)
936
+ const root = await repoRootOf(cwd, payload)
937
+ const ref = typeof payload.ref === 'string' && payload.ref.trim() !== '' ? requireRef(payload.ref, 'ref') : 'stash@{0}'
938
+ if (payload.confirm !== true) throw badRequest('dropping a stash requires confirm: true')
939
+ const sha = (await git(root, ['rev-parse', '--verify', ref])).trim()
940
+ await git(root, ['stash', 'drop', ref])
941
+ const undo = pushUndo(root, { kind: 'stash-drop', label: ref, hash: sha })
942
+ return { ref, hash: sha, undo: undo }
943
+ }
944
+
945
+ async function tagCreate(payload) {
946
+ const cwd = cwdOf(payload)
947
+ const root = await repoRootOf(cwd, payload)
948
+ const tag = requireRef(payload.name, 'name')
949
+ const args = ['tag']
950
+ if (typeof payload.message === 'string' && payload.message.trim() !== '') args.push('-a', tag, '-m', payload.message)
951
+ else args.push(tag)
952
+ if (typeof payload.hash === 'string' && payload.hash.trim() !== '') args.push(requireRef(payload.hash, 'hash'))
953
+ await git(root, args)
954
+ return { tag }
955
+ }
956
+
957
+ async function tagDelete(payload) {
958
+ const cwd = cwdOf(payload)
959
+ const root = await repoRootOf(cwd, payload)
960
+ const tag = requireRef(payload.name, 'name')
961
+ await git(root, ['tag', '-d', tag])
962
+ return { tag }
963
+ }
964
+
965
+ /** What this repository can still undo right now (newest last). */
966
+ async function undoList(payload) {
967
+ const cwd = cwdOf(payload)
968
+ const root = await repoRootOf(cwd, payload)
969
+ const items = undoEntriesOf(root).map((entry) => ({ id: entry.id, kind: entry.kind, label: entry.label, at: entry.at }))
970
+ return { items }
971
+ }
972
+
973
+ /**
974
+ * Undo one remembered action. Nothing here is a 'soft delete': the branch (or
975
+ * stash entry) really was gone, and the undo recreates it from the object id
976
+ * recorded before the deletion; a discard really overwrote the working tree,
977
+ * and the undo writes the snapshotted bytes back. It is one-shot and fails
978
+ * loudly when the world moved on — the name is taken again, or the discarded
979
+ * files were edited since.
980
+ */
981
+ async function undoApply(payload) {
982
+ const cwd = cwdOf(payload)
983
+ const root = await repoRootOf(cwd, payload)
984
+ const id = requireRef(payload.id, 'id')
985
+ const list = undoEntriesOf(root)
986
+ const index = list.findIndex((entry) => entry.id === id)
987
+ if (index < 0) throw new PanelError('undo-gone', 'this action can no longer be undone', 409)
988
+ const entry = list[index]
989
+ if (entry.kind === 'branch-delete') {
990
+ const exists = await runGit(root, ['show-ref', '--verify', '--quiet', 'refs/heads/' + entry.name])
991
+ if (exists.code === 0) throw new PanelError('undo-conflict', 'branch "' + entry.name + '" already exists again', 409)
992
+ await git(root, ['branch', entry.name, entry.hash])
993
+ } else if (entry.kind === 'stash-drop') {
994
+ await git(root, ['stash', 'store', '-m', 'dsh-ide-git: restored ' + entry.label, entry.hash])
995
+ } else if (entry.kind === 'discard') {
996
+ // Refuse to trample anything typed since the discard: every target must
997
+ // still look exactly the way the discard left it.
998
+ const now = snapshotState(root, entry.files)
999
+ for (let position = 0; position < now.length; position += 1) {
1000
+ if (now[position].hash !== entry.after[position].hash) {
1001
+ throw new PanelError('undo-conflict', 'these files changed after the discard', 409)
1002
+ }
1003
+ }
1004
+ for (const file of entry.files) {
1005
+ const target = path.join(root, file.rel)
1006
+ if (file.data === null) {
1007
+ // The file was absent before the discard: absent is what we restore.
1008
+ try { unlinkSync(target) } catch { /* already gone */ }
1009
+ continue
1010
+ }
1011
+ mkdirSync(path.dirname(target), { recursive: true })
1012
+ writeFileSync(target, file.data)
1013
+ }
1014
+ } else {
1015
+ throw new PanelError('undo-unsupported', 'this action cannot be undone', 500)
1016
+ }
1017
+ list.splice(index, 1)
1018
+ undoStacks.set(root, list)
1019
+ return { kind: entry.kind, label: entry.label }
1020
+ }
1021
+
1022
+ async function compare(payload) {
1023
+ const cwd = cwdOf(payload)
1024
+ const root = await repoRootOf(cwd, payload)
1025
+ const base = requireRef(payload.base, 'base')
1026
+ const head = requireRef(payload.head, 'head')
1027
+ const stat = await git(root, ['diff', '--numstat', '-z', base + '...' + head])
1028
+ const files = []
1029
+ const records = stat.split('\u0000')
1030
+ for (let index = 0; index < records.length; index += 1) {
1031
+ const record = records[index]
1032
+ if (record === '') continue
1033
+ const firstTab = record.indexOf('\t')
1034
+ const secondTab = record.indexOf('\t', firstTab + 1)
1035
+ if (firstTab < 0 || secondTab < 0) continue
1036
+ const addRaw = record.slice(0, firstTab)
1037
+ const delRaw = record.slice(firstTab + 1, secondTab)
1038
+ const binary = addRaw === '-' || delRaw === '-'
1039
+ files.push({
1040
+ path: record.slice(secondTab + 1),
1041
+ additions: binary ? 0 : Number.parseInt(addRaw, 10) || 0,
1042
+ deletions: binary ? 0 : Number.parseInt(delRaw, 10) || 0,
1043
+ binary,
1044
+ })
1045
+ }
1046
+ const logOut = await git(root, ['log', '--max-count=100', '--date-order', '--pretty=format:' + LOG_FORMAT, base + '..' + head])
1047
+ return { base, head, files, commits: parseLog(logOut) }
1048
+ }
1049
+
1050
+ /** Directories never descended into while looking for repositories. */
1051
+ const REPO_SCAN_SKIP = new Set(['node_modules', 'dist', 'build', 'out', 'target', 'vendor', 'venv', '.venv', '__pycache__', 'coverage', 'tmp', 'temp'])
1052
+
1053
+ /** Depth-capped search for repositories nested in a workspace. */
1054
+ function findNestedRepos(root, maxDepth) {
1055
+ const found = []
1056
+ const walk = (dir, depth) => {
1057
+ if (found.length >= 40 || depth > maxDepth) return
1058
+ let entries
1059
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
1060
+ for (const entry of entries) {
1061
+ if (!entry.isDirectory() || entry.name.startsWith('.') || REPO_SCAN_SKIP.has(entry.name)) continue
1062
+ const child = path.join(dir, entry.name)
1063
+ if (existsSync(path.join(child, '.git'))) { found.push(child); continue }
1064
+ if (depth < maxDepth) walk(child, depth + 1)
1065
+ }
1066
+ }
1067
+ walk(root, 1)
1068
+ return found
1069
+ }
1070
+
1071
+ async function branchOrNull(root) {
1072
+ const result = await runGit(root, ['rev-parse', '--abbrev-ref', 'HEAD'])
1073
+ if (result.code !== 0) return null
1074
+ const name = result.stdout.trim()
1075
+ return name === '' ? null : name
1076
+ }
1077
+
1078
+ /**
1079
+ * Repositories this panel may bind to: the session workspace itself when it is
1080
+ * one, plus repositories nested inside it (a sandbox holding many checkouts).
1081
+ * The client turns this into the repo picker — an empty workspace is answered
1082
+ * with the picker, not with an error.
1083
+ */
1084
+ async function repos(payload) {
1085
+ const cwd = cwdOf(payload)
1086
+ const list = []
1087
+ const self = await runGit(cwd, ['rev-parse', '--show-toplevel'])
1088
+ const isRepo = self.code === 0
1089
+ if (isRepo) {
1090
+ const top = self.stdout.trim()
1091
+ list.push({ path: top, name: path.basename(top), branch: await branchOrNull(top), kind: 'workspace' })
1092
+ }
1093
+ for (const dir of findNestedRepos(cwd, 2)) {
1094
+ if (list.some((entry) => entry.path === dir)) continue
1095
+ list.push({ path: dir, name: path.basename(dir), branch: await branchOrNull(dir), kind: 'nested' })
1096
+ }
1097
+ list.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'workspace' ? -1 : 1))
1098
+ return { cwd, isRepo, repos: list }
1099
+ }
1100
+
1101
+ async function version() {
1102
+ const result = await runGit(process.cwd(), ['--version'])
1103
+ const raw = result.code === 0 ? result.stdout.trim() : ''
1104
+ const match = /git version (\S+)/.exec(raw)
1105
+ return { git: match === null ? raw : match[1], available: result.code === 0 }
1106
+ }
1107
+
1108
+ function optionalPaths(payload, label) {
1109
+ if (payload === undefined || payload[label] === undefined || payload[label] === null) return []
1110
+ if (!Array.isArray(payload[label])) throw badRequest(label + ' must be an array')
1111
+ if (payload[label].length > 5000) throw badRequest(label + ' has too many entries')
1112
+ return payload[label].map((entry) => requireRelativePath(entry))
1113
+ }
1114
+
1115
+ function clampInt(value, min, max, fallback) {
1116
+ const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10)
1117
+ if (!Number.isFinite(parsed)) return fallback
1118
+ return Math.min(max, Math.max(min, Math.trunc(parsed)))
1119
+ }
1120
+
1121
+ const METHODS = {
1122
+ version,
1123
+ repos,
1124
+ summary,
1125
+ branches,
1126
+ log,
1127
+ commitDetail,
1128
+ diff,
1129
+ compare,
1130
+ stage,
1131
+ unstage,
1132
+ discard,
1133
+ commit,
1134
+ checkout,
1135
+ branchCreate,
1136
+ branchRename,
1137
+ branchDelete,
1138
+ merge,
1139
+ rebase,
1140
+ cherryPick,
1141
+ revert,
1142
+ reset,
1143
+ fetch,
1144
+ pull,
1145
+ push,
1146
+ stashList,
1147
+ stashPush,
1148
+ stashApply,
1149
+ stashDrop,
1150
+ tagCreate,
1151
+ tagDelete,
1152
+ undoList,
1153
+ undoApply,
1154
+ }
1155
+
1156
+ /* ============================== http plumbing ============================== */
1157
+
1158
+ function headerOf(req, name) {
1159
+ const value = req.headers[name]
1160
+ if (Array.isArray(value)) return value[0]
1161
+ return value
1162
+ }
1163
+
1164
+ /** Loopback authority, or a same-origin browser request (desktop shell / remote UI). */
1165
+ function isTrusted(req) {
1166
+ const host = headerOf(req, 'host')
1167
+ if (host === undefined || host === '') return false
1168
+ const hostname = host.replace(/^\[/, '').replace(/\]:\d+$/, '').replace(/:\d+$/, '')
1169
+ if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '0.0.0.0') return true
1170
+ if (/^127\./.test(hostname)) return true
1171
+ const site = headerOf(req, 'sec-fetch-site')
1172
+ return site === 'same-origin' || site === 'same-site'
1173
+ }
1174
+
1175
+ function sendJson(res, status, body) {
1176
+ const text = JSON.stringify(body)
1177
+ res.writeHead(status, {
1178
+ 'content-type': 'application/json; charset=utf-8',
1179
+ 'cache-control': 'no-store',
1180
+ 'content-length': Buffer.byteLength(text),
1181
+ })
1182
+ res.end(text)
1183
+ }
1184
+
1185
+ function readJsonBody(req, limit = 1 << 20) {
1186
+ return new Promise((resolve, reject) => {
1187
+ const chunks = []
1188
+ let size = 0
1189
+ req.on('data', (chunk) => {
1190
+ size += chunk.length
1191
+ if (size > limit) {
1192
+ reject(new PanelError('too-large', 'request body is too large', 413))
1193
+ req.destroy()
1194
+ return
1195
+ }
1196
+ chunks.push(chunk)
1197
+ })
1198
+ req.on('end', () => {
1199
+ const raw = Buffer.concat(chunks).toString('utf8')
1200
+ if (raw.trim() === '') { resolve({}); return }
1201
+ try {
1202
+ const parsed = JSON.parse(raw)
1203
+ resolve(parsed !== null && typeof parsed === 'object' ? parsed : {})
1204
+ } catch {
1205
+ reject(badRequest('request body must be JSON'))
1206
+ }
1207
+ })
1208
+ req.on('error', (error) => { reject(new PanelError('transport', messageOf(error), 500)) })
1209
+ })
1210
+ }
1211
+
1212
+ async function handle(req, res) {
1213
+ if (!isTrusted(req)) {
1214
+ sendJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
1215
+ return
1216
+ }
1217
+ if (req.method !== 'POST') {
1218
+ sendJson(res, 405, { ok: false, error: { code: 'method-error', message: 'POST required' } })
1219
+ return
1220
+ }
1221
+ const pathname = new URL(req.url === undefined ? '/' : req.url, 'http://dsh.internal').pathname
1222
+ const method = pathname.startsWith(ROUTE_PREFIX + '/') ? pathname.slice(ROUTE_PREFIX.length + 1) : ''
1223
+ const handler = Object.prototype.hasOwnProperty.call(METHODS, method) ? METHODS[method] : undefined
1224
+ if (handler === undefined || method.includes('/')) {
1225
+ sendJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method "' + method + '"' } })
1226
+ return
1227
+ }
1228
+ try {
1229
+ const payload = await readJsonBody(req)
1230
+ const data = WRITE_METHODS.has(method)
1231
+ ? await withRepoLock(lockKeyOf(payload), () => handler(payload))
1232
+ : await handler(payload)
1233
+ sendJson(res, 200, { ok: true, data: data })
1234
+ } catch (error) {
1235
+ if (error instanceof PanelError) {
1236
+ sendJson(res, error.status, { ok: false, error: { code: error.code, message: error.message } })
1237
+ return
1238
+ }
1239
+ const message = messageOf(error)
1240
+ sendJson(res, 500, { ok: false, error: { code: 'internal', message } })
1241
+ }
1242
+ }
1243
+
1244
+ export function apply(ctx) {
1245
+ ctx.effect(
1246
+ () => ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler: handle }),
1247
+ 'dsh-ide-git: /dsh-ide-git/api git routes',
1248
+ )
1249
+ }