dsh-ide-git 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -1,2329 +1,20 @@
1
1
  /**
2
- * dsh-ide-git — host half (plain JavaScript, no build step).
3
- *
4
- * One HTTP surface backs the browser panel:
2
+ * dsh-ide-git — host entry. The implementation lives under src/host/: this file
3
+ * only re-exports the public surface and wires the HTTP route plus the optional
4
+ * llm/sessions services used by the AI commit-message button.
5
5
  *
6
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
7
  */
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'
8
+ import { Config } from './host/config.js'
9
+ import { handle, ROUTE_PREFIX } from './host/http.js'
10
+ import { setCommitServices } from './host/commit-message.js'
11
+
12
+ export { Config }
13
+ export { repoScanCache, REPOS_CACHE_TTL_MS } from './host/repos.js'
23
14
 
24
15
  export const name = 'dsh-ide-git'
25
16
  export const inject = ['webServer']
26
17
 
27
- /* Row Config = the plugin's settings surface on dsh >= 0.1.7: the host persists
28
- these values under the loader row id ('ide-git', see cordis.patch.yml) and the
29
- browser reads them through ctx.configForms, so the settings card on the
30
- plugin's page and the action rail read ONE document. The rail's edit is one
31
- switch per action (`railRefresh` … `railPush`); a missing field means "default"
32
- (the action is shown), which is what keeps an action added by a later version
33
- visible on an older profile.
34
- `@deepseek-ai/schemastery` is resolved LAZILY: it is a host-provided module
35
- (every sibling plugin declares its row Config the same way), and on a host that
36
- cannot resolve it the row config is passed through unvalidated instead of the
37
- whole row disappearing — so the browser keeps its localStorage home there. */
38
- let Schema = null
39
- try {
40
- Schema = (await import('@deepseek-ai/schemastery')).default
41
- } catch (error) {
42
- console.warn('[dsh-ide-git] @deepseek-ai/schemastery is not resolvable here; the row Config surface is absent'
43
- + ' (the browser falls back to its localStorage rail config): ' + (error && error.message ? error.message : String(error)))
44
- }
45
-
46
- /** Rail action ids, in rail order. MUST mirror RAIL_SPECS in src/client.js. */
47
- const RAIL_CONFIG_IDS = [
48
- 'refresh', 'tree', 'worktrees', 'float', 'split', 'fullscreen', 'newBranch', 'checkout', 'delete',
49
- 'compare', 'diff', 'stash', 'tag', 'favorite', 'fetch', 'pull', 'push',
50
- ]
51
-
52
- /** Volatile refs (dsh >= 0.1.7 settings contract) or plain values on older hosts. */
53
- function live(schema) {
54
- return typeof schema.volatile === 'function' ? schema.volatile() : schema
55
- }
56
-
57
- function railConfigSchema(schema) {
58
- const shape = {}
59
- for (const id of RAIL_CONFIG_IDS) {
60
- shape['rail' + id.charAt(0).toUpperCase() + id.slice(1)] = live(schema.boolean().default(true))
61
- }
62
- /* AI commit message (issue #6), the three settings the user asked for. Empty
63
- string means "follow whatever the session is using" / "no extra prompt", so
64
- an untouched profile behaves exactly like the feature's default. */
65
- shape.commitModel = live(schema.string().default(''))
66
- shape.commitReasoning = live(schema.string().default(''))
67
- shape.commitPrompt = live(schema.string().default(''))
68
- /* Conversation-area view tab (issue #8): on by default, switchable off from
69
- the plugin settings card. false means "do not register the
70
- conversation.view seat at all" — the sidebar and dock seats are not
71
- affected; a missing field means the default (shown). */
72
- shape.conversationTab = live(schema.boolean().default(true))
73
- /* Immersive conversation seat (issue #8 round 3): on by default. false means
74
- the seat falls back to the pin-to-scrollport layout with a visible
75
- composer; a missing field means the default (on). NOTE: the field MUST be
76
- declared here — the Host rejects row-Config writes for paths the schema
77
- does not mark volatile ("Config field … is not volatile"), and the settings
78
- switch then rolls back silently. */
79
- shape.immersive = live(schema.boolean().default(true))
80
- /* Composer status pill (v0.19.0): the settings card's "输入框 Git 状态" switch
81
- and the pill itself read this field. v0.19.0-v0.19.6 shipped the switch and
82
- the reader WITHOUT this declaration, so the Host rejected every write with
83
- `Config field "composerStatus" is not volatile` — the card rolled the switch
84
- back and said "设置没写进去". Any new row-Config field the client writes MUST
85
- be declared (and volatile) here, or the write is refused before it lands. */
86
- shape.composerStatus = live(schema.boolean().default(true))
87
- return schema.object(shape)
88
- }
89
-
90
- export const Config = Schema === null ? undefined : railConfigSchema(Schema)
91
-
92
- const ROUTE_PREFIX = '/dsh-ide-git/api'
93
- const MAX_OUTPUT_BYTES = 8 * 1024 * 1024
94
-
95
- /* Ring buffer of the most recent git runs — the panel's "Git output" view
96
- (VS Code's scm showOutput equivalent). Bounded on both axes: entries and
97
- per-entry text, so a hostile `log -p` cannot grow it without limit. */
98
- const GIT_LOG_MAX_ENTRIES = 200
99
- const GIT_LOG_TEXT_MAX = 4000
100
- const gitLog = []
101
- function pushGitLog(entry) {
102
- gitLog.push(entry)
103
- if (gitLog.length > GIT_LOG_MAX_ENTRIES) gitLog.splice(0, gitLog.length - GIT_LOG_MAX_ENTRIES)
104
- }
105
- function clipGitLogText(text) {
106
- if (typeof text !== 'string' || text.length <= GIT_LOG_TEXT_MAX) return text
107
- return text.slice(0, GIT_LOG_TEXT_MAX / 2) + '\n…[' + String(text.length - GIT_LOG_TEXT_MAX) + ' more chars]…\n' + text.slice(-GIT_LOG_TEXT_MAX / 2)
108
- }
109
- const MAX_DIFF_CHARS = 400 * 1024
110
- const DEFAULT_TIMEOUT_MS = 30_000
111
- const NETWORK_TIMEOUT_MS = 180_000
112
- const RECORD_SEP = '\u001e'
113
- const FIELD_SEP = '\u001f'
114
-
115
- /* ============================== errors ============================== */
116
-
117
- class PanelError extends Error {
118
- constructor(code, message, status = 400) {
119
- super(message)
120
- this.code = code
121
- this.status = status
122
- }
123
- }
124
-
125
- function badRequest(message) {
126
- return new PanelError('bad-request', message, 400)
127
- }
128
-
129
- /* ============================== argv git ============================== */
130
-
131
- function gitBinary() {
132
- const override = process.env.DSH_IDE_GIT_BIN
133
- return override !== undefined && override.trim() !== '' ? override.trim() : 'git'
134
- }
135
-
136
- /**
137
- * Run git with an argv array (no shell). Resolves for ANY exit code so callers
138
- * can decide whether a non-zero status is data (e.g. `git diff --quiet`) or an
139
- * error; `git()` below is the throwing form.
140
- */
141
- function runGit(cwd, args, options = {}) {
142
- const timeoutMs = options.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : options.timeoutMs
143
- const startedAt = Date.now()
144
- return new Promise((resolve, reject) => {
145
- let child
146
- try {
147
- child = spawn(gitBinary(), args, { cwd, windowsHide: true, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' } })
148
- } catch (error) {
149
- reject(new PanelError('git-missing', 'cannot start git: ' + messageOf(error), 500))
150
- return
151
- }
152
- const out = []
153
- const err = []
154
- let outBytes = 0
155
- let errBytes = 0
156
- let settled = false
157
- const timer = setTimeout(() => {
158
- if (settled) return
159
- settled = true
160
- child.kill('SIGKILL')
161
- pushGitLog({ at: new Date(startedAt).toISOString(), argv: args.join(' '), code: 'timeout', ms: timeoutMs, out: '', err: 'timed out after ' + String(timeoutMs) + 'ms' })
162
- reject(new PanelError('git-timeout', 'git ' + args[0] + ' timed out after ' + timeoutMs + 'ms', 504))
163
- }, timeoutMs)
164
- child.stdout.on('data', (chunk) => {
165
- outBytes += chunk.length
166
- if (outBytes <= MAX_OUTPUT_BYTES) out.push(chunk)
167
- })
168
- child.stderr.on('data', (chunk) => {
169
- errBytes += chunk.length
170
- if (errBytes <= MAX_OUTPUT_BYTES) err.push(chunk)
171
- })
172
- child.on('error', (error) => {
173
- if (settled) return
174
- settled = true
175
- clearTimeout(timer)
176
- reject(new PanelError('git-missing', 'cannot run git: ' + messageOf(error), 500))
177
- })
178
- child.on('close', (code, signal) => {
179
- if (settled) return
180
- settled = true
181
- clearTimeout(timer)
182
- pushGitLog({
183
- at: new Date(startedAt).toISOString(),
184
- argv: args.join(' '),
185
- code: code === null ? -1 : code,
186
- ms: Date.now() - startedAt,
187
- out: clipGitLogText(Buffer.concat(out).toString('utf8')),
188
- err: clipGitLogText(Buffer.concat(err).toString('utf8')),
189
- })
190
- resolve({
191
- code: code === null ? -1 : code,
192
- signal: signal === null ? undefined : signal,
193
- stdout: Buffer.concat(out).toString('utf8'),
194
- stderr: Buffer.concat(err).toString('utf8'),
195
- truncated: outBytes > MAX_OUTPUT_BYTES,
196
- })
197
- })
198
- })
199
- }
200
-
201
- async function git(cwd, args, options = {}) {
202
- const result = await runGit(cwd, args, options)
203
- if (result.code !== 0) {
204
- const detail = result.stderr.trim() === '' ? result.stdout.trim() : result.stderr.trim()
205
- throw new PanelError('git-failed', 'git ' + args.join(' ') + ' failed: ' + (detail === '' ? 'exit ' + result.code : detail), 409)
206
- }
207
- return result.stdout
208
- }
209
-
210
- function messageOf(error) {
211
- return error instanceof Error ? error.message : String(error)
212
- }
213
-
214
- /* ============================== payload helpers ============================== */
215
-
216
- function requireAbsolute(value, label) {
217
- if (typeof value !== 'string' || value.trim() === '') throw badRequest(label + ' is required')
218
- if (!path.isAbsolute(value)) throw badRequest(label + ' must be an absolute path')
219
- return path.resolve(value)
220
- }
221
-
222
- /** A ref/branch/tag name: no whitespace, no leading dash, no control characters. */
223
- function requireRef(value, label) {
224
- if (typeof value !== 'string' || value.trim() === '') throw badRequest(label + ' is required')
225
- const name = value.trim()
226
- if (name.startsWith('-')) throw badRequest(label + ' must not start with "-"')
227
- if (/[\u0000-\u001f\u007f\s]/.test(name)) throw badRequest(label + ' contains invalid characters')
228
- return name
229
- }
230
-
231
- function optionalRevs(value, label) {
232
- if (value === undefined || value === null) return []
233
- if (!Array.isArray(value)) throw badRequest(label + ' must be an array')
234
- return value.map((entry) => requireRef(entry, label + ' entry'))
235
- }
236
-
237
- function requireMessage(value) {
238
- if (typeof value !== 'string' || value.trim() === '') throw badRequest('message is required')
239
- return value
240
- }
241
-
242
- function cwdOf(payload) {
243
- return requireAbsolute(payload === undefined ? undefined : payload.cwd, 'cwd')
244
- }
245
-
246
- /* ============================== git reads ============================== */
247
-
248
- async function repoRootOf(cwd, payload) {
249
- /* Normalize the separator: `rev-parse --show-toplevel` spells drive paths
250
- with forward slashes on Windows while path.join produces backslashes, so
251
- the untracked-diff fence (root + path.sep prefix check) rejected every
252
- in-repo path there. macOS/Linux are byte-identical no-ops. */
253
- if (payload !== undefined && payload.repoRoot !== undefined) return path.normalize(requireAbsolute(payload.repoRoot, 'repoRoot'))
254
- const result = await runGit(cwd, ['rev-parse', '--show-toplevel'])
255
- if (result.code !== 0) {
256
- // A workspace that is not a repository is a normal state, not a failure:
257
- // the client answers it with a repo picker instead of an error banner.
258
- throw new PanelError('not-a-repo', 'not a git repository: ' + cwd, 409)
259
- }
260
- const top = result.stdout.trim()
261
- return path.normalize(top === '' ? cwd : top)
262
- }
263
-
264
- async function currentBranchOf(root) {
265
- const name = (await git(root, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()
266
- return name
267
- }
268
-
269
- async function upstreamOf(root, branch) {
270
- const result = await runGit(root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', branch + '@{u}'])
271
- if (result.code !== 0) return null
272
- const name = result.stdout.trim()
273
- return name === '' ? null : name
274
- }
275
-
276
- async function aheadBehindOf(root, upstream, branch) {
277
- if (upstream === null) return { ahead: 0, behind: 0 }
278
- const result = await runGit(root, ['rev-list', '--left-right', '--count', upstream + '...' + branch])
279
- if (result.code !== 0) return { ahead: 0, behind: 0 }
280
- const parts = result.stdout.trim().split(/\s+/)
281
- const behind = Number.parseInt(parts[0], 10)
282
- const ahead = Number.parseInt(parts[1], 10)
283
- return { ahead: Number.isFinite(ahead) ? ahead : 0, behind: Number.isFinite(behind) ? behind : 0 }
284
- }
285
-
286
- /** Parse `git status --porcelain=v1 -z` (NUL separated, rename pairs follow). */
287
- function parsePorcelain(stdout) {
288
- const records = stdout.split('\u0000')
289
- const entries = []
290
- for (let index = 0; index < records.length; index += 1) {
291
- const record = records[index]
292
- if (record.length < 4) continue
293
- const x = record[0]
294
- const y = record[1]
295
- const filePath = record.slice(3)
296
- let origPath
297
- if (x === 'R' || x === 'C' || y === 'R' || y === 'C') {
298
- const next = records[index + 1]
299
- if (next !== undefined && next !== '') {
300
- origPath = next
301
- index += 1
302
- }
303
- }
304
- entries.push({ x, y, path: filePath, origPath })
305
- }
306
- return entries
307
- }
308
-
309
- function classify(entries) {
310
- const staged = []
311
- const unstaged = []
312
- const untracked = []
313
- const conflicted = []
314
- for (const entry of entries) {
315
- const pair = entry.x + entry.y
316
- if (pair === '??') { untracked.push(entry); continue }
317
- if (pair === '!!') continue
318
- if (pair === 'DD' || pair === 'AU' || pair === 'UD' || pair === 'UA' || pair === 'DU' || pair === 'AA' || pair === 'UU') {
319
- conflicted.push(entry)
320
- continue
321
- }
322
- if (entry.x !== ' ' && entry.x !== '?') staged.push(entry)
323
- if (entry.y !== ' ' && entry.y !== '?') unstaged.push(entry)
324
- }
325
- return { staged, unstaged, untracked, conflicted }
326
- }
327
-
328
- /** An ignored tree can be enormous, and this list is informational only. */
329
- const MAX_IGNORED_ENTRIES = 500
330
-
331
- /**
332
- * Ignored paths, asked for on their own. `git status -uall --ignored` expands
333
- * every file inside an ignored directory — node_modules alone would flood the
334
- * panel — while `ls-files --directory` reports the directory as a single entry.
335
- */
336
- async function ignoredEntriesOf(root) {
337
- const result = await runGit(root, ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'])
338
- if (result.code !== 0) return { entries: [], truncated: false }
339
- const paths = result.stdout.split('\u0000').filter((entry) => entry !== '')
340
- const kept = paths.slice(0, MAX_IGNORED_ENTRIES)
341
- return {
342
- entries: kept.map((filePath) => ({ index: '!', worktree: '!', path: filePath })),
343
- truncated: paths.length > kept.length,
344
- }
345
- }
346
-
347
- /** `git diff --numstat -z` -> Map(path -> { additions, deletions, binary }). */
348
- async function numstatOf(root, args) {
349
- const result = await runGit(root, ['diff', '--numstat', '-z', ...args])
350
- const map = new Map()
351
- if (result.code !== 0) return map
352
- const records = result.stdout.split('\u0000')
353
- for (let index = 0; index < records.length; index += 1) {
354
- const record = records[index]
355
- if (record === '') continue
356
- const firstTab = record.indexOf('\t')
357
- const secondTab = record.indexOf('\t', firstTab + 1)
358
- if (firstTab < 0 || secondTab < 0) continue
359
- const addRaw = record.slice(0, firstTab)
360
- const delRaw = record.slice(firstTab + 1, secondTab)
361
- let filePath = record.slice(secondTab + 1)
362
- if (filePath === '') {
363
- filePath = records[index + 1] === undefined ? '' : records[index + 1]
364
- index += 1
365
- const renamed = records[index + 1]
366
- if (renamed !== undefined && renamed !== '') index += 1
367
- }
368
- const binary = addRaw === '-' || delRaw === '-'
369
- map.set(filePath, {
370
- additions: binary ? 0 : Number.parseInt(addRaw, 10) || 0,
371
- deletions: binary ? 0 : Number.parseInt(delRaw, 10) || 0,
372
- binary,
373
- })
374
- }
375
- return map
376
- }
377
-
378
- function decorate(entries, numstat) {
379
- return entries.map((entry) => {
380
- const stat = numstat.get(entry.path)
381
- return {
382
- path: entry.path,
383
- origPath: entry.origPath,
384
- index: entry.x,
385
- worktree: entry.y,
386
- additions: stat === undefined ? 0 : stat.additions,
387
- deletions: stat === undefined ? 0 : stat.deletions,
388
- binary: stat === undefined ? false : stat.binary,
389
- }
390
- })
391
- }
392
-
393
- /** Parse the FIELD_SEP/RECORD_SEP pretty format used by `log`/`show`. */
394
- function parseLog(stdout) {
395
- const commits = []
396
- for (const record of stdout.split(RECORD_SEP)) {
397
- const trimmed = record.replace(/^\n+/, '')
398
- if (trimmed.trim() === '') continue
399
- const parts = trimmed.split(FIELD_SEP)
400
- if (parts.length < 8) continue
401
- commits.push({
402
- hash: parts[0],
403
- shortHash: parts[1],
404
- parents: parts[2].trim() === '' ? [] : parts[2].trim().split(' '),
405
- author: parts[3],
406
- email: parts[4],
407
- date: parts[5],
408
- subject: parts[6],
409
- refs: parts[7].trim() === '' ? [] : parts[7].split(',').map((ref) => ref.trim()).filter((ref) => ref !== ''),
410
- })
411
- }
412
- return commits
413
- }
414
-
415
- 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
416
-
417
- async function stashCountOf(root) {
418
- const result = await runGit(root, ['rev-list', '--walk-reflogs', '--count', 'refs/stash'])
419
- if (result.code !== 0) return 0
420
- const count = Number.parseInt(result.stdout.trim(), 10)
421
- return Number.isFinite(count) ? count : 0
422
- }
423
-
424
- /** Same directory, tolerating a trailing slash and macOS /var vs /private/var. */
425
- function sameDirectory(left, right) {
426
- const one = path.resolve(String(left))
427
- const two = path.resolve(String(right))
428
- if (one === two) return true
429
- try { return realpathSync(one) === realpathSync(two) } catch { return false }
430
- }
431
-
432
- /* `--porcelain -z` is the only parseable form: a path may contain spaces and
433
- newlines, and git realpaths the main worktree (/var -> /private/var on macOS),
434
- so every record is NUL-terminated and an empty record closes the current
435
- block. The flags describe the ADMIN view: `locked` and `prunable` say
436
- whether removal is blocked or the directory is already gone. */
437
- async function worktreesOf(root) {
438
- const result = await runGit(root, ['worktree', 'list', '--porcelain', '-z'])
439
- if (result.code !== 0) return []
440
- const trees = []
441
- let current = null
442
- const push = () => { if (current !== null) trees.push(current); current = null }
443
- for (const record of result.stdout.split('\u0000')) {
444
- if (record === '') { push(); continue }
445
- if (record.startsWith('worktree ')) {
446
- push()
447
- current = {
448
- path: record.slice('worktree '.length),
449
- branch: null,
450
- head: null,
451
- detached: false,
452
- bare: false,
453
- locked: false,
454
- lockReason: null,
455
- prunable: false,
456
- pruneReason: null,
457
- }
458
- continue
459
- }
460
- if (current === null) continue
461
- if (record.startsWith('HEAD ')) current.head = record.slice('HEAD '.length)
462
- else if (record.startsWith('branch ')) current.branch = record.slice('branch '.length).replace('refs/heads/', '')
463
- else if (record === 'detached') current.detached = true
464
- else if (record === 'bare') current.bare = true
465
- else if (record.startsWith('locked')) {
466
- current.locked = true
467
- const reason = record.slice('locked'.length).trim()
468
- current.lockReason = reason === '' ? null : reason
469
- } else if (record.startsWith('prunable')) {
470
- current.prunable = true
471
- const reason = record.slice('prunable'.length).trim()
472
- current.pruneReason = reason === '' ? null : reason
473
- }
474
- }
475
- push()
476
- return trees.map((tree, index) => Object.assign(tree, {
477
- primary: index === 0,
478
- current: sameDirectory(tree.path, root),
479
- }))
480
- }
481
-
482
- /* ============================== safety ============================== */
483
-
484
- /**
485
- * Write operations on one repository run one at a time. The client greys its
486
- * actions out while a request is in flight, but a stale tab, a second window or
487
- * a double click could still start two writers, and git dislikes concurrent
488
- * index writers — so the host serialises writes per repository.
489
- */
490
- const repoQueues = new Map()
491
-
492
- function withRepoLock(key, work) {
493
- const previous = repoQueues.get(key) === undefined ? Promise.resolve() : repoQueues.get(key)
494
- const next = previous.then(work, work)
495
- const settled = next.then(() => undefined, () => undefined)
496
- repoQueues.set(key, settled)
497
- settled.then(() => { if (repoQueues.get(key) === settled) repoQueues.delete(key) })
498
- return next
499
- }
500
-
501
- const WRITE_METHODS = new Set([
502
- 'stage', 'unstage', 'discard', 'commit', 'checkout', 'branchCreate', 'branchRename', 'branch-upstream',
503
- 'branchDelete', 'worktreeAdd', 'worktreeRemove', 'worktreePrune', 'merge', 'rebase', 'cherryPick', 'revert', 'reset', 'fetch', 'pull',
504
- 'push', 'stashPush', 'stashApply', 'stashDrop', 'stashClear', 'tagCreate', 'tagDelete', 'tagDeleteRemote', 'pushTags', 'clone', 'remoteAdd', 'remoteRemove', 'branchDeleteRemote', 'undoApply',
505
- ])
506
-
507
- function lockKeyOf(payload) {
508
- if (typeof payload.repoRoot === 'string' && payload.repoRoot !== '') return payload.repoRoot
509
- if (typeof payload.cwd === 'string' && payload.cwd !== '') return payload.cwd
510
- return 'global'
511
- }
512
-
513
- /* Deleting is a real delete — but the host remembers how to bring it back for a
514
- while, so the panel can offer an undo. Entries are per repository, newest
515
- last, capped and time-limited. */
516
- const UNDO_LIMIT = 20
517
- const UNDO_TTL_MS = 30 * 60 * 1000
518
- const undoStacks = new Map()
519
- let undoSeq = 0
520
-
521
- /** Branches whose deletion is never a routine action. */
522
- const PROTECTED_BRANCHES = new Set(['main', 'master', 'trunk'])
523
-
524
- function undoEntriesOf(root) {
525
- const stored = undoStacks.get(root) === undefined ? [] : undoStacks.get(root)
526
- const alive = stored.filter((entry) => Date.now() - entry.at < UNDO_TTL_MS)
527
- if (alive.length !== stored.length) undoStacks.set(root, alive)
528
- return alive
529
- }
530
-
531
- function pushUndo(root, entry) {
532
- const list = undoEntriesOf(root)
533
- undoSeq += 1
534
- const record = Object.assign({ id: 'u' + undoSeq.toString(36) + Date.now().toString(36), at: Date.now() }, entry)
535
- list.push(record)
536
- while (list.length > UNDO_LIMIT) list.shift()
537
- undoStacks.set(root, list)
538
- return { id: record.id, kind: record.kind, label: record.label }
539
- }
540
-
541
- /* Undoing a discard means writing the old bytes back, so the host has to keep
542
- them: a checkout/clean is the one moment they still exist. Snapshots stay
543
- deliberately conservative — regular files only, capped in count and in total
544
- size — and anything that cannot be reproduced faithfully (a symlink, a
545
- special file, a huge tree) yields NO undo handle at all rather than a promise
546
- the host cannot keep. */
547
- const UNDO_MAX_FILES = 400
548
- const UNDO_MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024
549
-
550
- function hashOf(data) {
551
- return createHash('sha1').update(data).digest('hex')
552
- }
553
-
554
- /** Repo-relative POSIX path of a target, or null when it escapes the repo. */
555
- function relInside(root, target) {
556
- const rel = path.relative(root, target)
557
- if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return null
558
- return rel.split(path.sep).join('/')
559
- }
560
-
561
- /**
562
- * Read every regular file the given paths cover (each path may be a file or a
563
- * directory). Returns null when the snapshot would not be faithful, which makes
564
- * the caller skip the undo handle instead of offering one it cannot honour.
565
- */
566
- function snapshotForUndo(root, paths) {
567
- const files = []
568
- let bytes = 0
569
- const collect = (target) => {
570
- const rel = relInside(root, target)
571
- if (rel === null) return true
572
- let stat
573
- try {
574
- stat = lstatSync(target)
575
- } catch (error) {
576
- // Already gone ('D' in the working tree) is itself a state worth undoing
577
- // to, so record the absence. Any other failure is not ours to guess at.
578
- if (error.code === 'ENOENT') { files.push({ rel: rel, data: null }); return true }
579
- return false
580
- }
581
- if (stat.isSymbolicLink()) return false
582
- if (stat.isDirectory()) {
583
- if (path.basename(target) === '.git') return true
584
- let names
585
- try { names = readdirSync(target) } catch { return false }
586
- for (const name of names) if (!collect(path.join(target, name))) return false
587
- return true
588
- }
589
- if (!stat.isFile()) return false
590
- if (files.length >= UNDO_MAX_FILES) return false
591
- let data
592
- try { data = readFileSync(target) } catch { return false }
593
- bytes += data.length
594
- if (bytes > UNDO_MAX_SNAPSHOT_BYTES) return false
595
- files.push({ rel: rel, data: data })
596
- return true
597
- }
598
- for (const entry of paths) if (!collect(path.resolve(root, entry))) return null
599
- return files
600
- }
601
-
602
- /**
603
- * The shape the snapshot targets have right now: a content hash, or null when
604
- * the path is absent. The undo compares against this before writing anything,
605
- * so content typed after the discard is never silently overwritten.
606
- */
607
- function snapshotState(root, files) {
608
- return files.map((file) => {
609
- try {
610
- return { rel: file.rel, hash: hashOf(readFileSync(path.join(root, file.rel))) }
611
- } catch {
612
- return { rel: file.rel, hash: null }
613
- }
614
- })
615
- }
616
-
617
- /** .git markers that mean a multi-step operation is still in progress. */
618
- async function operationOf(root) {
619
- const result = await runGit(root, ['rev-parse', '--absolute-git-dir'])
620
- if (result.code !== 0) return null
621
- const gitDir = result.stdout.trim()
622
- if (gitDir === '') return null
623
- const markers = [
624
- ['merge', 'MERGE_HEAD'],
625
- ['rebase', 'rebase-merge'],
626
- ['rebase', 'rebase-apply'],
627
- ['cherry-pick', 'CHERRY_PICK_HEAD'],
628
- ['revert', 'REVERT_HEAD'],
629
- ['bisect', 'BISECT_LOG'],
630
- ]
631
- for (const marker of markers) {
632
- if (existsSync(path.join(gitDir, marker[1]))) return marker[0]
633
- }
634
- return null
635
- }
636
-
637
- /* ============================== methods ============================== */
638
-
639
- /* The repository's own name, cut from its origin URL — a cloned folder that
640
- was renamed on disk still reports the name the remote gave it (the leaf of
641
- the URL, minus a trailing .git). scp-style (user@host:path), URL-style and
642
- plain local paths all cut on the last slash or colon. */
643
- function repoNameFromUrl(url) {
644
- if (typeof url !== 'string' || url.trim() === '') return ''
645
- const tail = url.trim().replace(/\/+$/, '').replace(/\.git$/i, '')
646
- const segments = tail.split(/[\\/:]/).filter((segment) => segment !== '')
647
- return segments.length > 0 ? segments[segments.length - 1] : ''
648
- }
649
- /* origin first, then the first configured remote, then '' — the client
650
- falls back to the folder basename when this is empty. Both reads are
651
- local config reads, never network. */
652
- async function originNameOf(root) {
653
- try {
654
- const origin = await git(root, ['remote', 'get-url', 'origin'])
655
- const name = repoNameFromUrl(origin)
656
- if (name !== '') return name
657
- } catch (error) { void error }
658
- try {
659
- const remotesOut = await runGit(root, ['remote'])
660
- if (remotesOut.code !== 0) return ''
661
- const first = remotesOut.stdout.split('\n').map((line) => line.trim()).filter((line) => line !== '')[0]
662
- if (first === undefined) return ''
663
- const urlOut = await runGit(root, ['remote', 'get-url', first])
664
- return urlOut.code === 0 ? repoNameFromUrl(urlOut.stdout.trim()) : ''
665
- } catch (error) { void error; return '' }
666
- }
667
-
668
- async function summary(payload) {
669
- const cwd = cwdOf(payload)
670
- const root = await repoRootOf(cwd, payload)
671
- /* Two-phase changes (issue #11): with `stats: false` the two `diff
672
- --numstat` passes — the most expensive step once a tree holds thousands
673
- of changes — are skipped, `statsDeferred: true` announces that the +/-
674
- numbers arrive through the `stats` method instead. The rows themselves
675
- are complete without them; nothing is truncated. */
676
- const deferStats = payload.stats === false
677
- /* The branch chain (branch → upstream → ahead/behind) is sequential by
678
- necessity; everything else is independent and runs concurrently, so a
679
- large `status` scan no longer serialises behind five cheap calls
680
- (issue #11: the sum of ~ten sequential commands became the max of two
681
- chains). */
682
- const [branchInfo, statusOut, unstagedStat, stagedStat, ignored, stashCount, worktrees, operation, head, originName] = await Promise.all([
683
- (async () => {
684
- const branch = await currentBranchOf(root)
685
- const detached = branch === 'HEAD'
686
- const upstream = detached ? null : await upstreamOf(root, branch)
687
- const tracking = upstream === null ? { ahead: 0, behind: 0 } : await aheadBehindOf(root, upstream, branch)
688
- return { branch, detached, upstream, tracking }
689
- })(),
690
- git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
691
- deferStats ? Promise.resolve(new Map()) : numstatOf(root, []),
692
- deferStats ? Promise.resolve(new Map()) : numstatOf(root, ['--cached']),
693
- payload.ignored === true ? ignoredEntriesOf(root) : { entries: [], truncated: false },
694
- stashCountOf(root),
695
- worktreesOf(root),
696
- operationOf(root),
697
- /* HEAD hash (v0.18.7): lets the client's poll notice OUTSIDE commits and
698
- checkouts — the panel's own actions already refresh everything, but
699
- nothing else watches the repo. Empty string on an unborn branch. */
700
- git(root, ['rev-parse', 'HEAD']).then((out) => out.trim()).catch(() => ''),
701
- originNameOf(root),
702
- ])
703
- const groups = classify(parsePorcelain(statusOut))
704
- return {
705
- repoRoot: root,
706
- cwd,
707
- branch: branchInfo.branch,
708
- detached: branchInfo.detached,
709
- upstream: branchInfo.upstream,
710
- ahead: branchInfo.tracking.ahead,
711
- behind: branchInfo.tracking.behind,
712
- head: head,
713
- /* The repository's own name (v0.19.1): the origin URL's leaf, so a
714
- renamed clone still shows the real repo name, not the folder's. */
715
- origin: originName,
716
- changes: {
717
- staged: decorate(groups.staged, stagedStat),
718
- unstaged: decorate(groups.unstaged, unstagedStat),
719
- untracked: decorate(groups.untracked, new Map()),
720
- conflicted: decorate(groups.conflicted, new Map()),
721
- ignored: ignored.entries,
722
- ignoredTruncated: ignored.truncated,
723
- },
724
- statsDeferred: deferStats === true,
725
- stashCount: stashCount,
726
- worktrees: worktrees,
727
- operation: operation,
728
- }
729
- }
730
-
731
- /* The second phase of the two-phase changes (issue #11): the two numstat
732
- passes, concurrent, packed as [path, additions, deletions, binary] rows so
733
- they survive JSON. Paths are post-rename paths — exactly what `summary`
734
- decorates its entries with. Read-only: deliberately not in WRITE_METHODS. */
735
- async function stats(payload) {
736
- const root = await repoRootOf(cwdOf(payload), payload)
737
- const [unstaged, staged] = await Promise.all([
738
- numstatOf(root, []),
739
- numstatOf(root, ['--cached']),
740
- ])
741
- const pack = (map) => {
742
- const rows = []
743
- for (const [path, stat] of map) rows.push([path, stat.additions, stat.deletions, stat.binary === true])
744
- return rows
745
- }
746
- return { unstaged: pack(unstaged), staged: pack(staged) }
747
- }
748
-
749
- async function branches(payload) {
750
- const cwd = cwdOf(payload)
751
- const root = await repoRootOf(cwd, payload)
752
- const branch = await currentBranchOf(root)
753
- const format = ['%(refname)', '%(refname:short)', '%(objectname)', '%(objectname:short)', '%(HEAD)', '%(upstream:short)', '%(committerdate:iso-strict)', '%(contents:subject)', '%(symref)'].join(FIELD_SEP)
754
- const out = await git(root, ['for-each-ref', '--format=' + format + RECORD_SEP, 'refs/heads', 'refs/remotes', 'refs/tags'])
755
- const local = []
756
- const remote = []
757
- const tags = []
758
- const worktrees = await worktreesOf(root)
759
- const busy = new Map()
760
- for (const tree of worktrees) {
761
- if (tree.branch === null || tree.branch === '') continue
762
- // `git worktree list` includes the checkout this repository lives in, so
763
- // without this the CURRENT branch was flagged as occupied by its own working
764
- // tree (reported on v0.3.11: main showed the marker while checked out here).
765
- // Only a SECOND working tree holding a branch blocks checking it out here.
766
- if (sameDirectory(tree.path, root)) continue
767
- busy.set(tree.branch, tree.path)
768
- }
769
- for (const record of out.split(RECORD_SEP)) {
770
- const trimmed = record.replace(/^\n+/, '')
771
- if (trimmed.trim() === '') continue
772
- const parts = trimmed.split(FIELD_SEP)
773
- if (parts.length < 9) continue
774
- const entry = {
775
- name: parts[1],
776
- hash: parts[2],
777
- shortHash: parts[3],
778
- head: parts[4] === '*',
779
- upstream: parts[5] === '' ? null : parts[5],
780
- date: parts[6],
781
- subject: parts[7],
782
- worktree: busy.get(parts[1]) === undefined ? null : busy.get(parts[1]),
783
- ahead: 0,
784
- behind: 0,
785
- }
786
- if (parts[0].startsWith('refs/heads/')) local.push(entry)
787
- else if (parts[0].startsWith('refs/remotes/')) {
788
- /* "<remote>/HEAD" is the symbolic pointer at a remote's default branch —
789
- its %(refname:short) collapses to the BARE remote name, so it is not a
790
- branch anyone can check out. It never becomes a row (the stray
791
- "<remote>" entry used to pollute the remote tree and, grouped, it also
792
- defeated the tree's single-remote prefix strip). */
793
- if (/^refs\/remotes\/[^/]+\/HEAD$/.test(parts[0]) === false) remote.push(entry)
794
- }
795
- else if (parts[0].startsWith('refs/tags/')) tags.push({ name: entry.name, hash: entry.hash, shortHash: entry.shortHash, date: entry.date, subject: entry.subject })
796
- }
797
- for (const entry of local) {
798
- if (entry.upstream === null) continue
799
- const tracking = await aheadBehindOf(root, entry.upstream, entry.name)
800
- entry.ahead = tracking.ahead
801
- entry.behind = tracking.behind
802
- }
803
- local.sort((a, b) => (a.head === b.head ? a.name.localeCompare(b.name) : a.head ? -1 : 1))
804
- remote.sort((a, b) => a.name.localeCompare(b.name))
805
- tags.sort((a, b) => a.name.localeCompare(b.name))
806
- const remotesOut = await runGit(root, ['remote'])
807
- const remotes = remotesOut.code === 0 ? remotesOut.stdout.split('\n').map((line) => line.trim()).filter((line) => line !== '') : []
808
- return { repoRoot: root, branch, local, remote, tags, remotes }
809
- }
810
-
811
- async function log(payload) {
812
- const cwd = cwdOf(payload)
813
- const root = await repoRootOf(cwd, payload)
814
- const limit = clampInt(payload.limit, 1, 500, 100)
815
- const skip = clampInt(payload.skip, 0, 1_000_000, 0)
816
- const args = ['log', '--max-count=' + (limit + 1), '--skip=' + skip, '--date-order', '--pretty=format:' + LOG_FORMAT]
817
- if (typeof payload.rev === 'string' && payload.rev.trim() !== '') args.push(requireRef(payload.rev, 'rev'))
818
- if (typeof payload.path === 'string' && payload.path.trim() !== '') args.push('--', payload.path)
819
- const out = await git(root, args)
820
- const all = parseLog(out)
821
- const hasMore = all.length > limit
822
- return { repoRoot: root, commits: hasMore ? all.slice(0, limit) : all, hasMore, skip, limit }
823
- }
824
- /* The merge that carried `from` INTO `to`, newest first — the anchor the
825
- branch tree's double-click-to-browse lands on (IDEA-style: double-click
826
- branch 2, then branch 1, and 1's list opens scrolled to the merge commit
827
- where 2 was merged in, so "2 has been merged here" is visible at a
828
- glance even though 1 kept moving. No merge relationship -> null, and the
829
- caller just opens the list at the top. Pure rev arithmetic, no network. */
830
- /* Who am I here: git config resolves user.name/email through its usual
831
- cascade (repo-local overrides global), so the answer is per repository.
832
- The client caches it per repo root for the whole panel lifetime — a git
833
- identity rarely changes mid-session — and pins it to the top of the
834
- author filter (v0.19.3), even when this branch has no commits by me:
835
- picking it and seeing an empty list IS the answer ("I never committed
836
- here"). Empty strings mean no identity is configured. */
837
- async function me(payload) {
838
- const cwd = cwdOf(payload)
839
- const root = await repoRootOf(cwd, payload)
840
- const name = await runGit(root, ['config', 'user.name'])
841
- const email = await runGit(root, ['config', 'user.email'])
842
- return {
843
- repoRoot: root,
844
- name: name.code === 0 ? name.stdout.trim() : '',
845
- email: email.code === 0 ? email.stdout.trim() : '',
846
- }
847
- }
848
-
849
- async function mergePoint(payload) {
850
- const cwd = cwdOf(payload)
851
- const root = await repoRootOf(cwd, payload)
852
- const from = requireRef(payload.from, 'from')
853
- const to = requireRef(payload.to, 'to')
854
- const out = await git(root, ['rev-list', '--merges', '--ancestry-path', '--max-count=1', from + '..' + to])
855
- const hash = out.trim().split('\n')[0] || ''
856
- return { repoRoot: root, hash: hash === '' ? null : hash }
857
- }
858
-
859
- async function commitDetail(payload) {
860
- const cwd = cwdOf(payload)
861
- const root = await repoRootOf(cwd, payload)
862
- const hash = requireRef(payload.hash, 'hash')
863
- const metaFormat = ['%H', '%h', '%P', '%an', '%ae', '%aI', '%s', '%b', '%D'].join(FIELD_SEP)
864
- const metaOut = await git(root, ['show', '-s', '--format=' + metaFormat, hash])
865
- const parts = metaOut.trim().split(FIELD_SEP)
866
- const parents = parts[2] === undefined || parts[2].trim() === '' ? [] : parts[2].trim().split(' ')
867
- const numstatOut = await git(root, ['show', '--numstat', '-z', '--format=', '-m', '--first-parent', hash])
868
- const files = []
869
- const records = numstatOut.split('\u0000')
870
- for (let index = 0; index < records.length; index += 1) {
871
- const record = records[index]
872
- if (record === '') continue
873
- const firstTab = record.indexOf('\t')
874
- const secondTab = record.indexOf('\t', firstTab + 1)
875
- if (firstTab < 0 || secondTab < 0) continue
876
- let filePath = record.slice(secondTab + 1)
877
- let origPath
878
- if (filePath === '') {
879
- origPath = records[index + 1]
880
- filePath = records[index + 2]
881
- index += 2
882
- }
883
- const addRaw = record.slice(0, firstTab)
884
- const delRaw = record.slice(firstTab + 1, secondTab)
885
- const binary = addRaw === '-' || delRaw === '-'
886
- files.push({
887
- path: filePath === undefined ? '' : filePath,
888
- origPath: origPath === undefined || origPath === '' ? undefined : origPath,
889
- additions: binary ? 0 : Number.parseInt(addRaw, 10) || 0,
890
- deletions: binary ? 0 : Number.parseInt(delRaw, 10) || 0,
891
- binary,
892
- })
893
- }
894
- return {
895
- hash: parts[0],
896
- shortHash: parts[1],
897
- parents,
898
- author: parts[3],
899
- email: parts[4],
900
- date: parts[5],
901
- subject: parts[6],
902
- body: parts[7] === undefined ? '' : parts[7],
903
- refs: parts[8] === undefined || parts[8].trim() === '' ? [] : parts[8].split(',').map((ref) => ref.trim()).filter((ref) => ref !== ''),
904
- files,
905
- }
906
- }
907
-
908
- async function diff(payload) {
909
- const cwd = cwdOf(payload)
910
- const root = await repoRootOf(cwd, payload)
911
- /* Untracked files have no index entry, so plain `git diff [--cached] -- path`
912
- is legitimately empty for them — the panel's change list still shows the
913
- file and the preview must not go blank. `--no-index /dev/null <file>`
914
- produces the honest "new file" patch; its exit code is 1 when differences
915
- exist, which is data, not failure (only >1 rejects). */
916
- if (payload.untracked === true) {
917
- const rel = requireRelativePath(payload.path)
918
- const absolute = path.isAbsolute(rel) ? rel : path.join(root, rel)
919
- /* --no-index reads the file for real, so unlike the plain `git diff --`
920
- form an absolute path must NOT be allowed to escape the repository. */
921
- if (absolute !== root && !absolute.startsWith(root + path.sep)) throw badRequest('path must stay inside the repository')
922
- /* git quotes and C-escapes backslash paths inside the diff header (and
923
- git ≥2.55 spells the a/ side with the real path instead of /dev/null);
924
- passing forward slashes keeps the header unquoted, so the rewrite below
925
- can match it verbatim. POSIX is a no-op. */
926
- const headerPath = absolute.split(path.sep).join('/')
927
- const result = await runGit(root, ['diff', '--no-color', '--no-ext-diff', '-U3', '--no-index', '--', '/dev/null', headerPath])
928
- if (result.code !== 0 && result.code !== 1) {
929
- const detail = result.stderr.trim() === '' ? result.stdout.trim() : result.stderr.trim()
930
- throw new PanelError('git-failed', 'git diff --no-index failed: ' + (detail === '' ? 'exit ' + result.code : detail), 409)
931
- }
932
- /* --no-index spells the b/ side with the absolute path we passed; rewrite
933
- the header lines back to the repository-relative spelling so the preview
934
- reads like every other patch (body lines are left untouched). */
935
- let out = result.stdout
936
- if (out.indexOf(headerPath) >= 0 && out.indexOf('\n') !== -1) {
937
- out = out.split('\n').map((line) => {
938
- if (line.startsWith('diff --git ') && line.indexOf(headerPath) >= 0) return 'diff --git a/' + rel + ' b/' + rel
939
- /* git ≥2.55 quotes the header and spells the a/ side with the real
940
- path instead of /dev/null, so match on the path, not the prefix. */
941
- if (line.startsWith('+++ ') && line.indexOf(headerPath) >= 0) return '+++ b/' + rel
942
- return line
943
- }).join('\n')
944
- }
945
- const truncated = out.length > MAX_DIFF_CHARS
946
- return { patch: truncated ? out.slice(0, MAX_DIFF_CHARS) : out, truncated }
947
- }
948
- const args = ['diff', '--no-color', '--no-ext-diff', '-U3']
949
- if (payload.staged === true) args.push('--cached')
950
- if (typeof payload.hash === 'string' && payload.hash.trim() !== '') {
951
- args.length = 0
952
- args.push('show', '--no-color', '--no-ext-diff', '-U3', '--format=', requireRef(payload.hash, 'hash'))
953
- }
954
- if (typeof payload.path === 'string' && payload.path.trim() !== '') args.push('--', requireRelativePath(payload.path))
955
- const out = await git(root, args)
956
- const truncated = out.length > MAX_DIFF_CHARS
957
- return { patch: truncated ? out.slice(0, MAX_DIFF_CHARS) : out, truncated }
958
- }
959
-
960
- function requireRelativePath(value) {
961
- if (typeof value !== 'string' || value.trim() === '') throw badRequest('path is required')
962
- const trimmed = value.trim()
963
- if (path.isAbsolute(trimmed)) return trimmed
964
- if (trimmed.split('/').includes('..')) throw badRequest('path must stay inside the repository')
965
- return trimmed
966
- }
967
-
968
- async function stage(payload) {
969
- const cwd = cwdOf(payload)
970
- const root = await repoRootOf(cwd, payload)
971
- const paths = optionalPaths(payload, 'paths')
972
- await git(root, ['add', '-A', '--', ...paths])
973
- return { staged: paths.length }
974
- }
975
-
976
- async function unstage(payload) {
977
- const cwd = cwdOf(payload)
978
- const root = await repoRootOf(cwd, payload)
979
- const paths = optionalPaths(payload, 'paths')
980
- const result = await runGit(root, ['reset', '-q', 'HEAD', '--', ...paths])
981
- if (result.code !== 0) await git(root, ['rm', '--cached', '-r', '-q', '--', ...paths])
982
- return { unstaged: paths.length }
983
- }
984
-
985
- async function discard(payload) {
986
- const cwd = cwdOf(payload)
987
- const root = await repoRootOf(cwd, payload)
988
- // '.' would discard EVERYTHING in the repository in one go; the panel always
989
- // discards explicit paths, so the root itself is not a valid target here.
990
- const paths = optionalPaths(payload, 'paths').filter((entry) => entry !== '.' && entry !== './')
991
- if (paths.length === 0) throw badRequest('paths is required')
992
- const untracked = payload.untracked === true
993
- if (untracked && payload.confirm !== true) throw badRequest('discarding untracked files requires confirm: true')
994
- // Last chance to keep the bytes: the checkout/clean below is what destroys them.
995
- const files = snapshotForUndo(root, paths)
996
- if (untracked) await git(root, ['clean', '-f', '-d', '--', ...paths])
997
- else await git(root, ['checkout', '--', ...paths])
998
- // A snapshot the host cannot reproduce faithfully means no handle at all — the
999
- // panel says so instead of showing an undo that would fail later.
1000
- if (files === null) return { discarded: paths.length, untracked: untracked, undoBlocked: true }
1001
- const label = paths.length === 1 ? paths[0] : paths.length + ' paths'
1002
- const undo = pushUndo(root, { kind: 'discard', label: label, files: files, after: snapshotState(root, files) })
1003
- return { discarded: paths.length, untracked: untracked, undo: undo }
1004
- }
1005
-
1006
- async function commit(payload) {
1007
- const cwd = cwdOf(payload)
1008
- const root = await repoRootOf(cwd, payload)
1009
- const message = requireMessage(payload.message)
1010
- const paths = (payload.paths === undefined || payload.paths === null) ? [] : optionalPaths(payload, 'paths')
1011
- if (paths.length > 0) await git(root, ['add', '-A', '--', ...paths])
1012
- const args = ['commit', '-m', message]
1013
- if (payload.amend === true) args.push('--amend')
1014
- if (payload.signoff === true) args.push('--signoff')
1015
- const out = await git(root, args)
1016
- const result = { output: out.trim() }
1017
- if (payload.push === true) {
1018
- // Commit-and-push is one gesture in the panel, so the commit is published in
1019
- // the same request. A failed push must not hide the commit that did land.
1020
- if (payload.confirm !== true) throw badRequest('pushing requires confirm: true')
1021
- const pushed = await runGit(root, ['push'])
1022
- result.pushed = pushed.code === 0
1023
- if (pushed.code !== 0) result.pushError = (pushed.stderr.trim() || pushed.stdout.trim() || 'push failed')
1024
- }
1025
- return result
1026
- }
1027
-
1028
- async function checkout(payload) {
1029
- const cwd = cwdOf(payload)
1030
- const root = await repoRootOf(cwd, payload)
1031
- if (payload.create === true) {
1032
- const name = requireRef(payload.branch, 'branch')
1033
- const start = typeof payload.startPoint === 'string' && payload.startPoint.trim() !== '' ? requireRef(payload.startPoint, 'startPoint') : undefined
1034
- const args = ['checkout', '-b', name]
1035
- /* track (issue #18): "create a local branch from a remote one" must bind
1036
- the upstream in the same breath — checkout -b alone never does. */
1037
- if (payload.track === true && start !== undefined) args.push('--track')
1038
- if (start !== undefined) args.push(start)
1039
- await git(root, args)
1040
- return { branch: name, created: true }
1041
- }
1042
- const target = requireRef(payload.branch, 'branch')
1043
- await git(root, ['checkout', target])
1044
- return { branch: target, created: false }
1045
- }
1046
-
1047
- async function branchCreate(payload) {
1048
- const cwd = cwdOf(payload)
1049
- const root = await repoRootOf(cwd, payload)
1050
- const name = requireRef(payload.name, 'name')
1051
- const args = ['branch', name]
1052
- if (typeof payload.startPoint === 'string' && payload.startPoint.trim() !== '') args.push(requireRef(payload.startPoint, 'startPoint'))
1053
- await git(root, args)
1054
- return { branch: name }
1055
- }
1056
-
1057
- /* ---------- worktrees (v0.22, alpha.2) ----------
1058
- A worktree is a second checkout of the same repository. These three verbs
1059
- are the whole lifecycle the panel offers: create a checkout (on a new or an
1060
- existing branch), remove one, and prune the admin records of directories
1061
- that are already gone.
1062
-
1063
- Removal is deliberately strict. The current and primary checkouts are
1064
- refused outright (removing the tree the panel is reading is never what a
1065
- click meant), a locked worktree must be unlocked first, and a dirty one
1066
- needs a second request with force:true. The dirty check runs INSIDE the
1067
- target, not the repository root: git status at the root says nothing
1068
- about the linked checkout's files. */
1069
- async function worktreeAdd(payload) {
1070
- const cwd = cwdOf(payload)
1071
- const root = await repoRootOf(cwd, payload)
1072
- const target = requireAbsolute(payload.path, 'path')
1073
- if (sameDirectory(target, root)) throw badRequest('path must differ from the current worktree')
1074
- if (existsSync(target)) throw new PanelError('worktree-exists', 'path already exists: ' + target, 409)
1075
- const branch = requireRef(payload.branch, 'branch')
1076
- const checked = await runGit(root, ['check-ref-format', '--branch', branch])
1077
- if (checked.code !== 0) throw badRequest('invalid branch name: ' + branch)
1078
- const createBranch = payload.createBranch !== false
1079
- const args = ['worktree', 'add']
1080
- if (createBranch === true) {
1081
- args.push('-b', branch, target)
1082
- if (typeof payload.base === 'string' && payload.base.trim() !== '') args.push(requireRef(payload.base, 'base'))
1083
- } else {
1084
- args.push(target, branch)
1085
- }
1086
- await git(root, args)
1087
- return { path: target, branch: branch, created: createBranch }
1088
- }
1089
-
1090
- async function worktreeRemove(payload) {
1091
- const cwd = cwdOf(payload)
1092
- const root = await repoRootOf(cwd, payload)
1093
- if (payload.confirm !== true) throw badRequest('removing a worktree requires confirm: true')
1094
- const target = requireAbsolute(payload.path, 'path')
1095
- const trees = await worktreesOf(root)
1096
- const tree = trees.find((entry) => sameDirectory(entry.path, target))
1097
- if (tree === undefined) throw new PanelError('worktree-missing', 'not a worktree of this repository: ' + target, 409)
1098
- if (tree.current === true) throw new PanelError('protected-worktree', 'refusing to remove the worktree this panel is showing', 409)
1099
- if (tree.primary === true) throw new PanelError('protected-worktree', 'refusing to remove the primary worktree', 409)
1100
- if (tree.locked === true) throw new PanelError('worktree-locked', 'worktree is locked; unlock it before removing: ' + target, 409)
1101
- if (tree.prunable === true || existsSync(target) === false) throw new PanelError('worktree-missing', 'worktree directory is gone; prune the record instead: ' + target, 409)
1102
- const dirty = await runGit(target, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])
1103
- if (dirty.code !== 0) throw new PanelError('worktree-unreadable', 'could not inspect the worktree: ' + target, 409)
1104
- const hasChanges = dirty.stdout !== ''
1105
- if (hasChanges === true && payload.force !== true) throw new PanelError('worktree-dirty', 'worktree has uncommitted changes; force removal deletes them: ' + target, 409)
1106
- const args = ['worktree', 'remove']
1107
- if (payload.force === true) args.push('--force')
1108
- args.push(target)
1109
- await git(root, args)
1110
- return { path: target, forced: payload.force === true, dirty: hasChanges }
1111
- }
1112
-
1113
- async function worktreePrune(payload) {
1114
- const cwd = cwdOf(payload)
1115
- const root = await repoRootOf(cwd, payload)
1116
- if (payload.confirm !== true) throw badRequest('pruning worktrees requires confirm: true')
1117
- const output = await git(root, ['worktree', 'prune', '-v'])
1118
- return { output: output.trim() }
1119
- }
1120
-
1121
- /* Upstream management (issue #18): rebind or drop a branch's tracking remote.
1122
- git refuses --set-upstream-to with a ref it cannot resolve, so a typo dies
1123
- here instead of silently writing branch.<name>.merge. */
1124
- async function branchUpstream(payload) {
1125
- const cwd = cwdOf(payload)
1126
- const root = await repoRootOf(cwd, payload)
1127
- const branch = requireRef(payload.branch, 'branch')
1128
- if (payload.unset === true) {
1129
- /* --unset-upstream errors on a branch that has none; the client only
1130
- offers the verb when the row shows an upstream, and git's own message
1131
- is the right one to surface if they raced. */
1132
- await git(root, ['branch', '--unset-upstream', branch])
1133
- return { branch, upstream: null }
1134
- }
1135
- const upstream = requireRef(payload.upstream, 'upstream')
1136
- await git(root, ['branch', '--set-upstream-to=' + upstream, branch])
1137
- return { branch, upstream }
1138
- }
1139
-
1140
- async function branchRename(payload) {
1141
- const cwd = cwdOf(payload)
1142
- const root = await repoRootOf(cwd, payload)
1143
- const from = requireRef(payload.from, 'from')
1144
- const to = requireRef(payload.to, 'to')
1145
- await git(root, ['branch', '-m', from, to])
1146
- return { from, to }
1147
- }
1148
-
1149
- async function branchDelete(payload) {
1150
- const cwd = cwdOf(payload)
1151
- const root = await repoRootOf(cwd, payload)
1152
- const force = payload.force === true
1153
- if (force && payload.confirm !== true) throw badRequest('force delete requires confirm: true')
1154
- /* Batch form (multi-select): `names` wins when present; the single `name`
1155
- keeps working exactly as before. Every name goes through the same ref
1156
- guard — an array never bypasses validation. */
1157
- const names = Array.isArray(payload.names)
1158
- ? payload.names.map((entry) => requireRef(entry, 'name'))
1159
- : [requireRef(payload.name, 'name')]
1160
- const current = await currentBranchOf(root)
1161
- for (const name of names) {
1162
- if (name === current) throw new PanelError('protected-branch', 'refusing to delete the checked-out branch "' + name + '"', 409)
1163
- if (PROTECTED_BRANCHES.has(name) && payload.confirm !== true) throw badRequest('deleting "' + name + '" requires confirm: true')
1164
- }
1165
- const branches = []
1166
- for (const name of names) {
1167
- const head = (await git(root, ['rev-parse', '--verify', 'refs/heads/' + name])).trim()
1168
- branches.push({ name: name, hash: head })
1169
- }
1170
- // -d (the default) refuses to drop an unmerged branch: git is the guard here.
1171
- await git(root, ['branch', force ? '-D' : '-d', ...names])
1172
- const label = names.length === 1 ? names[0] : names.length + ' branches'
1173
- const undo = pushUndo(root, {
1174
- kind: 'branch-delete', label: label,
1175
- branches: branches,
1176
- // Legacy single-branch fields stay so an in-flight old handle keeps working.
1177
- name: names[0], hash: branches[0].hash,
1178
- })
1179
- return { branch: names[0], hash: branches[0].hash, names: names, branches: branches, force: force, undo: undo }
1180
- }
1181
-
1182
- async function merge(payload) {
1183
- const cwd = cwdOf(payload)
1184
- const root = await repoRootOf(cwd, payload)
1185
- const branch = requireRef(payload.branch, 'branch')
1186
- const args = ['merge', '--no-edit']
1187
- if (payload.ffOnly === true) args.push('--ff-only')
1188
- if (payload.noFf === true) args.push('--no-ff')
1189
- if (payload.squash === true) args.push('--squash')
1190
- args.push(branch)
1191
- const result = await runGit(root, args)
1192
- const combined = (result.stdout + '\n' + result.stderr).trim()
1193
- const conflicted = /CONFLICT|Automatic merge failed/.test(combined)
1194
- if (result.code !== 0 && !conflicted) throw new PanelError('git-failed', combined === '' ? 'merge failed' : combined, 409)
1195
- return { branch, conflicted, output: combined }
1196
- }
1197
-
1198
- async function rebase(payload) {
1199
- const cwd = cwdOf(payload)
1200
- const root = await repoRootOf(cwd, payload)
1201
- const onto = requireRef(payload.onto, 'onto')
1202
- const result = await runGit(root, ['rebase', onto])
1203
- const combined = (result.stdout + '\n' + result.stderr).trim()
1204
- const conflicted = /CONFLICT|could not apply/.test(combined)
1205
- if (result.code !== 0 && !conflicted) throw new PanelError('git-failed', combined === '' ? 'rebase failed' : combined, 409)
1206
- return { onto, conflicted, output: combined }
1207
- }
1208
-
1209
- /* VS Code's "Abort Rebase" (SCM ▸ Commit ▸ Abort Rebase). Only meaningful
1210
- while a rebase is in progress — `operationOf` already surfaces that state,
1211
- so the menu greys the entry out; the route still refuses honestly when
1212
- there is nothing to abort. */
1213
- async function rebaseAbort(payload) {
1214
- const cwd = cwdOf(payload)
1215
- const root = await repoRootOf(cwd, payload)
1216
- const result = await runGit(root, ['rebase', '--abort'])
1217
- if (result.code !== 0) {
1218
- const detail = (result.stdout + '\n' + result.stderr).trim()
1219
- throw new PanelError('git-failed', detail === '' ? 'no rebase to abort' : detail, 409)
1220
- }
1221
- return { output: (result.stdout + '\n' + result.stderr).trim() }
1222
- }
1223
-
1224
- /* VS Code's SCM ▸ Remote ▸ Add Remote.... The name is a ref, the URL the same
1225
- grammar `clone` already accepts. */
1226
- async function remoteAdd(payload) {
1227
- const cwd = cwdOf(payload)
1228
- const root = await repoRootOf(cwd, payload)
1229
- const name = requireRef(payload.name, 'name')
1230
- const url = requireUrl(payload.url, 'url')
1231
- await git(root, ['remote', 'add', name, url])
1232
- return { name, url }
1233
- }
1234
-
1235
- async function remoteRemove(payload) {
1236
- const cwd = cwdOf(payload)
1237
- const root = await repoRootOf(cwd, payload)
1238
- const name = requireRef(payload.name, 'name')
1239
- if (typeof payload.confirm !== 'boolean' || payload.confirm !== true) throw badRequest('removing a remote requires confirm: true')
1240
- await git(root, ['remote', 'remove', name])
1241
- return { name }
1242
- }
1243
-
1244
- /* SCM ▸ Branch ▸ Delete Remote Branch...: `git push <remote> --delete <branch>`.
1245
- Genuinely destructive on the OTHER side, so it keeps the typed-confirm
1246
- posture of a local protected-branch delete: confirm is mandatory. */
1247
- async function branchDeleteRemote(payload) {
1248
- const cwd = cwdOf(payload)
1249
- const root = await repoRootOf(cwd, payload)
1250
- const remote = requireRef(payload.remote, 'remote')
1251
- /* Batch form (multi-select): `branches` wins; the single `branch` keeps
1252
- working. `git push --delete` accepts several names in one go. */
1253
- const branches = Array.isArray(payload.branches)
1254
- ? payload.branches.map((entry) => requireRef(entry, 'branch'))
1255
- : [requireRef(payload.branch, 'branch')]
1256
- if (payload.confirm !== true) throw badRequest('deleting a remote branch requires confirm: true')
1257
- const out = await git(root, ['push', remote, '--delete', ...branches], { timeoutMs: NETWORK_TIMEOUT_MS })
1258
- return { remote: remote, branch: branches[0], branches: branches, output: out.trim() }
1259
- }
1260
-
1261
- /* SCM ▸ Tags ▸ Delete Remote Tag... / Push Tags. */
1262
- async function tagDeleteRemote(payload) {
1263
- const cwd = cwdOf(payload)
1264
- const root = await repoRootOf(cwd, payload)
1265
- const remote = requireRef(payload.remote, 'remote')
1266
- const tag = requireRef(payload.name, 'name')
1267
- if (payload.confirm !== true) throw badRequest('deleting a remote tag requires confirm: true')
1268
- const out = await git(root, ['push', remote, '--delete', tag], { timeoutMs: NETWORK_TIMEOUT_MS })
1269
- return { remote, tag, output: out.trim() }
1270
- }
1271
-
1272
- async function pushTags(payload) {
1273
- const cwd = cwdOf(payload)
1274
- const root = await repoRootOf(cwd, payload)
1275
- if (payload.confirm !== true) throw badRequest('pushing tags requires confirm: true')
1276
- const remote = typeof payload.remote === 'string' && payload.remote.trim() !== '' ? requireRef(payload.remote, 'remote') : 'origin'
1277
- const out = await git(root, ['push', remote, '--tags'], { timeoutMs: NETWORK_TIMEOUT_MS })
1278
- return { output: out.trim() }
1279
- }
1280
-
1281
- /* SCM ▸ Stash ▸ Drop All Stashes...: one `git stash clear`. No undo handle is
1282
- possible (the stash reflog is gone with the clear), hence the mandatory
1283
- confirm on top of the dialog. */
1284
- async function stashClear(payload) {
1285
- const cwd = cwdOf(payload)
1286
- const root = await repoRootOf(cwd, payload)
1287
- if (payload.confirm !== true) throw badRequest('dropping all stashes requires confirm: true')
1288
- await git(root, ['stash', 'clear'])
1289
- return { cleared: true }
1290
- }
1291
-
1292
- async function cherryPick(payload) {
1293
- const cwd = cwdOf(payload)
1294
- const root = await repoRootOf(cwd, payload)
1295
- /* Batch form (multi-select): `hashes` are applied in the order given — the
1296
- client sends them oldest-first so the sequence replays topologically. The
1297
- single `hash` keeps working. */
1298
- const hashes = Array.isArray(payload.hashes)
1299
- ? payload.hashes.map((entry) => requireRef(entry, 'hash'))
1300
- : [requireRef(payload.hash, 'hash')]
1301
- const result = await runGit(root, ['cherry-pick', ...hashes])
1302
- const combined = (result.stdout + '\n' + result.stderr).trim()
1303
- const conflicted = /CONFLICT/.test(combined)
1304
- if (result.code !== 0 && !conflicted) throw new PanelError('git-failed', combined === '' ? 'cherry-pick failed' : combined, 409)
1305
- return { hash: hashes[0], hashes: hashes, conflicted: conflicted, output: combined }
1306
- }
1307
-
1308
- async function revert(payload) {
1309
- const cwd = cwdOf(payload)
1310
- const root = await repoRootOf(cwd, payload)
1311
- const hash = requireRef(payload.hash, 'hash')
1312
- const result = await runGit(root, ['revert', '--no-edit', hash])
1313
- const combined = (result.stdout + '\n' + result.stderr).trim()
1314
- const conflicted = /CONFLICT/.test(combined)
1315
- if (result.code !== 0 && !conflicted) throw new PanelError('git-failed', combined === '' ? 'revert failed' : combined, 409)
1316
- return { hash, conflicted, output: combined }
1317
- }
1318
-
1319
- async function reset(payload) {
1320
- const cwd = cwdOf(payload)
1321
- const root = await repoRootOf(cwd, payload)
1322
- const hash = requireRef(payload.hash, 'hash')
1323
- const mode = payload.mode === 'soft' || payload.mode === 'hard' ? payload.mode : 'mixed'
1324
- if (mode === 'hard' && payload.confirm !== true) throw badRequest('hard reset requires confirm: true')
1325
- await git(root, ['reset', '--' + mode, hash])
1326
- return { hash, mode }
1327
- }
1328
-
1329
- async function fetch(payload) {
1330
- const cwd = cwdOf(payload)
1331
- const root = await repoRootOf(cwd, payload)
1332
- const args = ['fetch']
1333
- if (typeof payload.remote === 'string' && payload.remote.trim() !== '') args.push(requireRef(payload.remote, 'remote'))
1334
- // A refspec (`main:main`) fast-forwards a NON-checked-out branch in place —
1335
- // this is how "pull a branch that is not HEAD" works without switching to
1336
- // it (git pull would merge into HEAD instead). git refuses a non-ff update
1337
- // here, which is exactly the safety we want.
1338
- if (typeof payload.branch === 'string' && payload.branch.trim() !== '') args.push(requireRef(payload.branch, 'refspec'))
1339
- if (payload.all === true) args.push('--all')
1340
- // prune/--tags serve the repo-wide fetch; a refspec fetch is deliberately
1341
- // narrow and must not drag every tag over the wire.
1342
- if (payload.branch === undefined && payload.prune !== false) args.push('--prune')
1343
- if (payload.branch === undefined) args.push('--tags')
1344
- const out = await git(root, args, { timeoutMs: NETWORK_TIMEOUT_MS })
1345
- return { output: (out + '\n').trim() }
1346
- }
1347
-
1348
- async function pull(payload) {
1349
- const cwd = cwdOf(payload)
1350
- const root = await repoRootOf(cwd, payload)
1351
- const mode = payload.mode === 'rebase' || payload.mode === 'merge' ? payload.mode : 'ff-only'
1352
- const args = ['pull']
1353
- if (mode === 'ff-only') args.push('--ff-only')
1354
- if (mode === 'rebase') args.push('--rebase')
1355
- if (mode === 'merge') args.push('--no-rebase')
1356
- if (typeof payload.remote === 'string' && payload.remote.trim() !== '') args.push(requireRef(payload.remote, 'remote'))
1357
- if (typeof payload.branch === 'string' && payload.branch.trim() !== '') args.push(requireRef(payload.branch, 'branch'))
1358
- const out = await git(root, args, { timeoutMs: NETWORK_TIMEOUT_MS })
1359
- return { mode, output: out.trim() }
1360
- }
1361
-
1362
- async function push(payload) {
1363
- const cwd = cwdOf(payload)
1364
- const root = await repoRootOf(cwd, payload)
1365
- if (payload.confirm !== true) throw badRequest('push requires confirm: true')
1366
- const args = ['push']
1367
- if (payload.setUpstream === true) args.push('--set-upstream')
1368
- if (payload.force === true) args.push('--force-with-lease')
1369
- if (typeof payload.remote === 'string' && payload.remote.trim() !== '') args.push(requireRef(payload.remote, 'remote'))
1370
- if (typeof payload.branch === 'string' && payload.branch.trim() !== '') args.push(requireRef(payload.branch, 'branch'))
1371
- const out = await git(root, args, { timeoutMs: NETWORK_TIMEOUT_MS })
1372
- return { output: (out + '\n').trim() }
1373
- }
1374
-
1375
- async function stashList(payload) {
1376
- const cwd = cwdOf(payload)
1377
- const root = await repoRootOf(cwd, payload)
1378
- const out = await git(root, ['stash', 'list', '--pretty=format:%gd' + FIELD_SEP + '%gs' + FIELD_SEP + '%aI' + RECORD_SEP])
1379
- const stashes = []
1380
- for (const record of out.split(RECORD_SEP)) {
1381
- const trimmed = record.replace(/^\n+/, '')
1382
- if (trimmed.trim() === '') continue
1383
- const parts = trimmed.split(FIELD_SEP)
1384
- stashes.push({ ref: parts[0], subject: parts[1] === undefined ? '' : parts[1], date: parts[2] === undefined ? '' : parts[2] })
1385
- }
1386
- return { stashes }
1387
- }
1388
-
1389
- async function stashPush(payload) {
1390
- const cwd = cwdOf(payload)
1391
- const root = await repoRootOf(cwd, payload)
1392
- const args = ['stash', 'push']
1393
- if (payload.includeUntracked === true) args.push('--include-untracked')
1394
- if (payload.staged === true) args.push('--staged')
1395
- if (typeof payload.message === 'string' && payload.message.trim() !== '') args.push('-m', payload.message)
1396
- const out = await git(root, args)
1397
- return { output: out.trim() }
1398
- }
1399
-
1400
- async function stashApply(payload) {
1401
- const cwd = cwdOf(payload)
1402
- const root = await repoRootOf(cwd, payload)
1403
- const ref = typeof payload.ref === 'string' && payload.ref.trim() !== '' ? requireRef(payload.ref, 'ref') : 'stash@{0}'
1404
- const args = ['stash', payload.pop === true ? 'pop' : 'apply', ref]
1405
- const result = await runGit(root, args)
1406
- const combined = (result.stdout + '\n' + result.stderr).trim()
1407
- const conflicted = /CONFLICT/.test(combined)
1408
- if (result.code !== 0 && !conflicted) throw new PanelError('git-failed', combined === '' ? 'stash apply failed' : combined, 409)
1409
- return { ref, conflicted, output: combined }
1410
- }
1411
-
1412
- async function stashDrop(payload) {
1413
- const cwd = cwdOf(payload)
1414
- const root = await repoRootOf(cwd, payload)
1415
- const ref = typeof payload.ref === 'string' && payload.ref.trim() !== '' ? requireRef(payload.ref, 'ref') : 'stash@{0}'
1416
- if (payload.confirm !== true) throw badRequest('dropping a stash requires confirm: true')
1417
- const sha = (await git(root, ['rev-parse', '--verify', ref])).trim()
1418
- await git(root, ['stash', 'drop', ref])
1419
- const undo = pushUndo(root, { kind: 'stash-drop', label: ref, hash: sha })
1420
- return { ref, hash: sha, undo: undo }
1421
- }
1422
-
1423
- async function tagCreate(payload) {
1424
- const cwd = cwdOf(payload)
1425
- const root = await repoRootOf(cwd, payload)
1426
- const tag = requireRef(payload.name, 'name')
1427
- const args = ['tag']
1428
- if (typeof payload.message === 'string' && payload.message.trim() !== '') args.push('-a', tag, '-m', payload.message)
1429
- else args.push(tag)
1430
- if (typeof payload.hash === 'string' && payload.hash.trim() !== '') args.push(requireRef(payload.hash, 'hash'))
1431
- await git(root, args)
1432
- return { tag }
1433
- }
1434
-
1435
- async function tagDelete(payload) {
1436
- const cwd = cwdOf(payload)
1437
- const root = await repoRootOf(cwd, payload)
1438
- /* Batch form (multi-select): `names` wins; the single `name` keeps working. */
1439
- const names = Array.isArray(payload.names)
1440
- ? payload.names.map((entry) => requireRef(entry, 'name'))
1441
- : [requireRef(payload.name, 'name')]
1442
- await git(root, ['tag', '-d', ...names])
1443
- return { tag: names[0], names: names }
1444
- }
1445
-
1446
- /* VS Code's SCM title "Clone..." — clone a URL into an absolute local path.
1447
- The PARENT of the target must exist; git creates the last component itself
1448
- and refuses a non-empty one, which is the safety net for typos. It writes a
1449
- brand-new tree (never touches an existing repository), but it does write the
1450
- filesystem, so it rides the write queue like everything else that mutates. */
1451
- const CLONE_TIMEOUT_MS = 10 * 60 * 1000
1452
- function requireUrl(value, label) {
1453
- if (typeof value !== 'string' || value.trim() === '') throw badRequest(label + ' is required')
1454
- const text = value.trim()
1455
- if (text.startsWith('-') === true || /[\s\u0000-\u001f]/.test(text) === true || text.length > 2048) {
1456
- throw badRequest(label + ' is not a valid repository URL')
1457
- }
1458
- return text
1459
- }
1460
- async function clone(payload) {
1461
- const url = requireUrl(payload.url, 'url')
1462
- const dir = requireAbsolute(payload.dir, 'dir')
1463
- await git(path.dirname(dir), ['clone', url, dir], { timeoutMs: CLONE_TIMEOUT_MS })
1464
- return { dir }
1465
- }
1466
-
1467
- /* The tail of the git run ring buffer (see runGit). Read-only, global. */
1468
- async function gitLogs() {
1469
- return { entries: gitLog.slice(-GIT_LOG_MAX_ENTRIES) }
1470
- }
1471
-
1472
- /** What this repository can still undo right now (newest last). */
1473
- async function undoList(payload) {
1474
- const cwd = cwdOf(payload)
1475
- const root = await repoRootOf(cwd, payload)
1476
- const items = undoEntriesOf(root).map((entry) => ({ id: entry.id, kind: entry.kind, label: entry.label, at: entry.at }))
1477
- return { items }
1478
- }
1479
-
1480
- /**
1481
- * Undo one remembered action. Nothing here is a 'soft delete': the branch (or
1482
- * stash entry) really was gone, and the undo recreates it from the object id
1483
- * recorded before the deletion; a discard really overwrote the working tree,
1484
- * and the undo writes the snapshotted bytes back. It is one-shot and fails
1485
- * loudly when the world moved on — the name is taken again, or the discarded
1486
- * files were edited since.
1487
- */
1488
- async function undoApply(payload) {
1489
- const cwd = cwdOf(payload)
1490
- const root = await repoRootOf(cwd, payload)
1491
- const id = requireRef(payload.id, 'id')
1492
- const list = undoEntriesOf(root)
1493
- const index = list.findIndex((entry) => entry.id === id)
1494
- if (index < 0) throw new PanelError('undo-gone', 'this action can no longer be undone', 409)
1495
- const entry = list[index]
1496
- if (entry.kind === 'branch-delete') {
1497
- /* Batch handles carry `branches: [{name, hash}]`; older single-branch
1498
- handles only have `name`/`hash` — normalise both to one list. */
1499
- const restores = Array.isArray(entry.branches) ? entry.branches : [{ name: entry.name, hash: entry.hash }]
1500
- for (const item of restores) {
1501
- const exists = await runGit(root, ['show-ref', '--verify', '--quiet', 'refs/heads/' + item.name])
1502
- if (exists.code === 0) throw new PanelError('undo-conflict', 'branch "' + item.name + '" already exists again', 409)
1503
- await git(root, ['branch', item.name, item.hash])
1504
- }
1505
- } else if (entry.kind === 'stash-drop') {
1506
- await git(root, ['stash', 'store', '-m', 'dsh-ide-git: restored ' + entry.label, entry.hash])
1507
- } else if (entry.kind === 'discard') {
1508
- // Refuse to trample anything typed since the discard: every target must
1509
- // still look exactly the way the discard left it.
1510
- const now = snapshotState(root, entry.files)
1511
- for (let position = 0; position < now.length; position += 1) {
1512
- if (now[position].hash !== entry.after[position].hash) {
1513
- throw new PanelError('undo-conflict', 'these files changed after the discard', 409)
1514
- }
1515
- }
1516
- for (const file of entry.files) {
1517
- const target = path.join(root, file.rel)
1518
- if (file.data === null) {
1519
- // The file was absent before the discard: absent is what we restore.
1520
- try { unlinkSync(target) } catch { /* already gone */ }
1521
- continue
1522
- }
1523
- mkdirSync(path.dirname(target), { recursive: true })
1524
- writeFileSync(target, file.data)
1525
- }
1526
- } else {
1527
- throw new PanelError('undo-unsupported', 'this action cannot be undone', 500)
1528
- }
1529
- list.splice(index, 1)
1530
- undoStacks.set(root, list)
1531
- return { kind: entry.kind, label: entry.label }
1532
- }
1533
-
1534
- async function compare(payload) {
1535
- const cwd = cwdOf(payload)
1536
- const root = await repoRootOf(cwd, payload)
1537
- const base = requireRef(payload.base, 'base')
1538
- const head = requireRef(payload.head, 'head')
1539
- const stat = await git(root, ['diff', '--numstat', '-z', base + '...' + head])
1540
- const files = []
1541
- const records = stat.split('\u0000')
1542
- for (let index = 0; index < records.length; index += 1) {
1543
- const record = records[index]
1544
- if (record === '') continue
1545
- const firstTab = record.indexOf('\t')
1546
- const secondTab = record.indexOf('\t', firstTab + 1)
1547
- if (firstTab < 0 || secondTab < 0) continue
1548
- const addRaw = record.slice(0, firstTab)
1549
- const delRaw = record.slice(firstTab + 1, secondTab)
1550
- const binary = addRaw === '-' || delRaw === '-'
1551
- files.push({
1552
- path: record.slice(secondTab + 1),
1553
- additions: binary ? 0 : Number.parseInt(addRaw, 10) || 0,
1554
- deletions: binary ? 0 : Number.parseInt(delRaw, 10) || 0,
1555
- binary,
1556
- })
1557
- }
1558
- const logOut = await git(root, ['log', '--max-count=100', '--date-order', '--pretty=format:' + LOG_FORMAT, base + '..' + head])
1559
- return { base, head, files, commits: parseLog(logOut) }
1560
- }
1561
-
1562
- /** Directories never descended into while looking for repositories. */
1563
- const REPO_SCAN_SKIP = new Set(['node_modules', 'dist', 'build', 'out', 'target', 'vendor', 'venv', '.venv', '__pycache__', 'coverage', 'tmp', 'temp'])
1564
-
1565
- /* `git submodule status --recursive` already reports every nesting level of a
1566
- repository in ONE call (paths arrive fully qualified), so the submodule walk
1567
- costs exactly one `status` per checkout; the lists stay bounded so a
1568
- pathological tree cannot flood the picker. */
1569
- const SUBMODULE_LIST_LIMIT = 400
1570
- const REPO_LIST_LIMIT = 60
1571
-
1572
- /** Slash-normalized path, for comparing paths that came from different
1573
- * producers (git prints forward slashes; `path.join` prints backslashes on
1574
- * Windows). Case-insensitive on Windows so the same checkout reached two ways
1575
- * collapses to one row. */
1576
- function pathIdentity(value) {
1577
- const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '')
1578
- const key = process.platform === 'win32' ? normalized.toLowerCase() : normalized
1579
- /* realpath collapses producer spellings of one checkout: git's
1580
- * --show-toplevel resolves symlinks (/private/var on macOS) while a
1581
- * directory walk keeps the spelling it was handed (/var), and without this
1582
- * the same submodule arrived as two rows. An unresolvable path falls back
1583
- * to the slash-normalized form. */
1584
- try {
1585
- return realpathSync(key).replace(/\\/g, '/').replace(/\/+$/, '')
1586
- } catch {
1587
- return key
1588
- }
1589
- }
1590
-
1591
- /** One repository row shipped to the client (`kind` drives the picker badge). */
1592
- function repoRow(root, submodulePath, kind) {
1593
- return {
1594
- path: root.replace(/\\/g, '/'),
1595
- /** Full checkout name; a submodule carries its repo-relative path so two
1596
- * same-named checkouts stay apart in a long list. */
1597
- name: submodulePath === undefined ? path.basename(root) : submodulePath,
1598
- label: submodulePath === undefined ? path.basename(root) : submodulePath.split('/').pop(),
1599
- branch: null,
1600
- /** The checkout's own repository name, cut from its origin URL (v0.19.2):
1601
- * the picker shows this first and falls back to the directory name, with
1602
- * the directory name as the small sub line whenever the two differ — so
1603
- * two clones of one repository stay tellable apart. Filled in by the
1604
- * scan below (an origin-less checkout keeps the empty string). */
1605
- origin: '',
1606
- kind,
1607
- ...(submodulePath === undefined ? {} : { submodulePath }),
1608
- }
1609
- }
1610
-
1611
- /**
1612
- * Every checkout registered as a submodule under `root`, at any depth.
1613
- *
1614
- * Git itself is the source of truth: `git submodule status --recursive` lists
1615
- * the registered paths (relative to the parent repository) even for submodules
1616
- * that were never initialised, and `--recursive` walks EVERY nesting level in
1617
- * that one call — deeper entries arrive as fully qualified paths
1618
- * ("outer/inner"), so one `status` per checkout is the whole discovery. (An
1619
- * earlier revision also re-ran `status` inside every confirmed submodule; those
1620
- * calls could only re-list checkouts the recursive pass had already reported,
1621
- * which multiplied the process count on a workspace full of checkouts without
1622
- * producing a single new row.) A path that is present on disk and passes
1623
- * `rev-parse --show-toplevel` is a real, independent working tree — the parent
1624
- * only ever sees it as one gitlink line, so this is what makes it selectable
1625
- * as a repository of its own (the SourceTree behavior).
1626
- *
1627
- * The working-tree checks all run at once, because a repository with a dozen
1628
- * submodules otherwise costs a dozen sequential processes on every scan.
1629
- */
1630
- async function collectSubmoduleRepos(root) {
1631
- const found = []
1632
- const listing = await runGit(root, ['submodule', 'status', '--recursive'])
1633
- if (listing.code !== 0) return found
1634
- const candidates = []
1635
- for (const raw of listing.stdout.split('\n')) {
1636
- if (candidates.length >= SUBMODULE_LIST_LIMIT) break
1637
- const line = raw.replace(/\r$/, '')
1638
- if (line.trim() === '') continue
1639
- /* `<state><sha> <path> (<describe>)`: the state prefix is ' ', '+', '-' or 'U'. */
1640
- const match = /^[\s+\-U]([0-9a-f]{4,64})\s+(.+?)(?:\s+\(.*\))?$/.exec(line)
1641
- const rel = (match === null ? line.trim().replace(/^[\s+\-U]\S*\s+/, '') : match[2]).trim().replace(/\\/g, '/')
1642
- if (rel === '') continue
1643
- const absolute = path.resolve(root, rel)
1644
- /* An uninitialised submodule has no `.git` yet: git still registers it, but
1645
- there is no working tree to bind the panel to. */
1646
- if (!existsSync(path.join(absolute, '.git'))) continue
1647
- candidates.push({ absolute, rel })
1648
- }
1649
- const tops = await Promise.all(candidates.map((entry) => runGit(entry.absolute, ['rev-parse', '--show-toplevel'])))
1650
- for (let index = 0; index < candidates.length; index += 1) {
1651
- const top = tops[index]
1652
- if (top.code !== 0) continue
1653
- const reported = top.stdout.trim().replace(/\\/g, '/').replace(/\/+$/, '')
1654
- found.push({ root: reported === '' ? candidates[index].absolute : reported, rel: candidates[index].rel })
1655
- }
1656
- return found
1657
- }
1658
-
1659
- /* The whole discovery pass is cached in-process per cwd: one scan walks the
1660
- tree and spawns a git process per checkout, while the client re-POSTs `repos`
1661
- on every panel mount — a warm answer is what keeps switching back to the Git
1662
- page instant on a workspace full of checkouts. Entries are { at, rows };
1663
- REPOS_CACHE_TTL_MS is a module-level `let` so tests can pin it to 0 and
1664
- observe the always-rescan path.
1665
-
1666
- The cache is deliberately NOT invalidated when a write method succeeds:
1667
- `repos` is a read-only discovery of which checkouts EXIST, and no write
1668
- method changes that set — a commit or a new branch does not conjure a
1669
- repository. A clone landing inside the 15s window simply stays invisible
1670
- until the next scan; that is an accepted trade, the client's background
1671
- refresh is the safety net. */
1672
- export const repoScanCache = new Map()
1673
- export let REPOS_CACHE_TTL_MS = 15000
1674
-
1675
- async function scanRepos(cwd) {
1676
- const cached = repoScanCache.get(cwd)
1677
- /* Hand back fresh row objects: the cache is shared by every caller, and a
1678
- consumer that decorates or reorders what it received must not write
1679
- through into the next request's answer. */
1680
- if (cached !== undefined && Date.now() - cached.at < REPOS_CACHE_TTL_MS) {
1681
- return { cached: true, rows: cached.rows.map((row) => ({ ...row })) }
1682
- }
1683
- const list = []
1684
- const self = await runGit(cwd, ['rev-parse', '--show-toplevel'])
1685
- const isRepo = self.code === 0
1686
- if (isRepo) {
1687
- const top = self.stdout.trim()
1688
- list.push(repoRow(top === '' ? cwd : top, undefined, 'workspace'))
1689
- }
1690
- for (const dir of findNestedRepos(cwd, 2)) list.push(repoRow(dir, undefined, 'nested'))
1691
- /* Submodules of EVERY discovered checkout, not only a workspace that is one:
1692
- a container workspace (a folder holding several checkouts — the usual
1693
- shape of a multi-repo project) reaches its repositories through the rows
1694
- above, and each of those may carry submodules of its own. Without this
1695
- pass the picker lists `eis` but none of the eight checkouts under it.
1696
- These rows are built LAST and the map below lets them win: the directory
1697
- scan can reach the same checkout through a plain path (a submodule whose
1698
- parent directory is not itself a repository), and the submodule row is the
1699
- better one — its name carries the repo-relative path. */
1700
- const roots = list.map((row) => row.path)
1701
- const submoduleLists = await Promise.all(roots.map((root) => collectSubmoduleRepos(root)))
1702
- for (const entries of submoduleLists) {
1703
- for (const entry of entries) list.push(repoRow(entry.root, entry.rel, 'nested'))
1704
- }
1705
- const byIdentity = new Map()
1706
- for (const row of list) byIdentity.set(pathIdentity(row.path), row)
1707
- const rows = [...byIdentity.values()]
1708
- rows.sort((a, b) => (a.kind === b.kind ? a.path.localeCompare(b.path) : a.kind === 'workspace' ? -1 : 1))
1709
- const capped = rows.slice(0, REPO_LIST_LIMIT).map((row) => ({ ...row }))
1710
- const branches = await Promise.all(capped.map((row) => branchOrNull(row.path)))
1711
- for (let index = 0; index < capped.length; index += 1) capped[index].branch = branches[index]
1712
- /* Origin names ride the same one-call-per-row pass as the branches: a local
1713
- `git remote get-url` read, cheap, and the 15s cache means repeated picker
1714
- opens pay for it once. */
1715
- const origins = await Promise.all(capped.map((row) => originNameOf(row.path)))
1716
- for (let index = 0; index < capped.length; index += 1) capped[index].origin = origins[index]
1717
- repoScanCache.set(cwd, { at: Date.now(), rows: capped })
1718
- return { cached: false, rows: capped.map((row) => ({ ...row })) }
1719
- }
1720
-
1721
- /** Depth-capped search for repositories nested in a workspace. */
1722
- function findNestedRepos(root, maxDepth) {
1723
- const found = []
1724
- const walk = (dir, depth) => {
1725
- if (found.length >= 40 || depth > maxDepth) return
1726
- let entries
1727
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
1728
- for (const entry of entries) {
1729
- if (!entry.isDirectory() || entry.name.startsWith('.') || REPO_SCAN_SKIP.has(entry.name)) continue
1730
- const child = path.join(dir, entry.name)
1731
- if (existsSync(path.join(child, '.git'))) { found.push(child); continue }
1732
- if (depth < maxDepth) walk(child, depth + 1)
1733
- }
1734
- }
1735
- walk(root, 1)
1736
- return found
1737
- }
1738
-
1739
- async function branchOrNull(root) {
1740
- const result = await runGit(root, ['rev-parse', '--abbrev-ref', 'HEAD'])
1741
- if (result.code !== 0) return null
1742
- const name = result.stdout.trim()
1743
- return name === '' ? null : name
1744
- }
1745
-
1746
- /**
1747
- * Repositories this panel may bind to: the session workspace itself when it is
1748
- * one, repositories nested inside it (a sandbox holding many checkouts), and
1749
- * the submodules of every checkout discovered that way — git records each
1750
- * submodule as its own working tree with its own HEAD, index and branches, so
1751
- * it is a repository the panel can bind to like any other. The client turns
1752
- * this into the repo picker, and picking a row is all it takes: every later
1753
- * call carries that path as `repoRoot`, which is the working directory the git
1754
- * commands run in.
1755
- */
1756
- async function repos(payload) {
1757
- const cwd = cwdOf(payload)
1758
- const { cached, rows } = await scanRepos(cwd)
1759
- /* `cached: true` rides a cache hit only; the client ignores it today, it just
1760
- makes the answer say where it came from. */
1761
- return { cwd, isRepo: rows.some((row) => row.kind === 'workspace'), repos: rows, ...(cached ? { cached: true } : {}) }
1762
- }
1763
-
1764
- async function version() {
1765
- const result = await runGit(process.cwd(), ['--version'])
1766
- const raw = result.code === 0 ? result.stdout.trim() : ''
1767
- const match = /git version (\S+)/.exec(raw)
1768
- return { git: match === null ? raw : match[1], available: result.code === 0 }
1769
- }
1770
-
1771
- function optionalPaths(payload, label) {
1772
- if (payload === undefined || payload[label] === undefined || payload[label] === null) return []
1773
- if (!Array.isArray(payload[label])) throw badRequest(label + ' must be an array')
1774
- if (payload[label].length > 5000) throw badRequest(label + ' has too many entries')
1775
- return payload[label].map((entry) => requireRelativePath(entry))
1776
- }
1777
-
1778
- function clampInt(value, min, max, fallback) {
1779
- const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10)
1780
- if (!Number.isFinite(parsed)) return fallback
1781
- return Math.min(max, Math.max(min, Math.trunc(parsed)))
1782
- }
1783
-
1784
- /* ============================== ai commit message ============================== */
1785
- /* Issue #6: write the commit message with the session's own model route. The
1786
- settings arrive WITH the request (the browser reads them from the same row
1787
- Config document it renders), so a setting change takes effect on the next
1788
- click without the host caching anything. */
1789
- const COMMIT_MAX_PROMPT_CHARS = 2000
1790
- const COMMIT_TOTAL_BYTES = 12 * 1024
1791
- const COMMIT_FILE_BYTES = 2048
1792
- const COMMIT_FILE_LINES = 80
1793
- const COMMIT_MAX_FILES = 30
1794
- const COMMIT_BINARY = /\.(png|jpe?g|gif|webp|ico|pdf|zip|gz|tgz|bz2|xz|7z|jar|war|woff2?|ttf|otf|eot|mp[34]|mov|avi|mkv|so|dylib|dll|exe|bin|wasm|class|o|a)$/i
1795
-
1796
- /** One model call's text, plus how it ENDED (a failure arrives as a finish chunk). */
1797
- async function collectCommitStream(stream) {
1798
- let text = ''
1799
- let finish = null
1800
- let sawReasoning = false
1801
- for await (const chunk of stream) {
1802
- if (chunk === null || chunk === undefined) continue
1803
- if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
1804
- else if (chunk.type === 'reasoning-delta') sawReasoning = true
1805
- else if (chunk.type === 'finish') finish = chunk.reason
1806
- }
1807
- return { text: text, finish: finish, sawReasoning: sawReasoning }
1808
- }
1809
-
1810
- /** The change set, trimmed to a budget the model can actually be asked about. */
1811
- function commitInputOf(stagedPatch, unstagedPatch) {
1812
- const patch = (stagedPatch === '' ? '' : stagedPatch) + (unstagedPatch === '' ? '' : (stagedPatch === '' ? '' : '\n') + unstagedPatch)
1813
- const files = []
1814
- let bytes = 0
1815
- let dropped = 0
1816
- let truncated = false
1817
- const parts = patch.split(/^diff --git /m).slice(1)
1818
- for (const part of parts) {
1819
- const head = /^a\/(.+?) b\//.exec(part)
1820
- const name = head === null ? '(unknown)' : head[1]
1821
- if (files.length >= COMMIT_MAX_FILES) { dropped += 1; continue }
1822
- if (COMMIT_BINARY.test(name)) { dropped += 1; continue }
1823
- if (/(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|composer\.lock)$/.test(name)) { dropped += 1; continue }
1824
- let body = 'diff --git ' + part
1825
- let trimmed = false
1826
- const lines = body.split('\n')
1827
- if (lines.length > COMMIT_FILE_LINES) { body = lines.slice(0, COMMIT_FILE_LINES).join('\n'); trimmed = true }
1828
- if (body.length > COMMIT_FILE_BYTES) { body = body.slice(0, COMMIT_FILE_BYTES); trimmed = true }
1829
- /* A file the total budget then drops was never sent, so it must not report
1830
- as "truncated": truncated means "the model saw a shortened version". */
1831
- if (bytes + body.length > COMMIT_TOTAL_BYTES) { dropped += 1; continue }
1832
- if (trimmed === true) truncated = true
1833
- bytes += body.length
1834
- files.push({ path: name, body: body })
1835
- }
1836
- return { files: files, bytes: bytes, dropped: dropped, truncated: truncated }
1837
- }
1838
-
1839
- /** Built-in instruction + the change set + the user's own addition, as DATA. */
1840
- function commitMessagesOf(input, userPrompt) {
1841
- const system = [
1842
- 'You write git commit messages.',
1843
- 'Reply with the commit message ONLY: no prose, no code fences, no quotes, no explanation.',
1844
- 'First line: a concise subject, at most 72 characters, no trailing period.',
1845
- 'Then one blank line and a short body explaining what changed and why, when it adds value.',
1846
- 'Describe only what the diff shows. Never invent files, features or issue numbers.',
1847
- ].join('\n')
1848
- const user = [
1849
- 'Write the commit message for these changes.',
1850
- '',
1851
- input.files.map((file) => file.body).join('\n'),
1852
- ].join('\n')
1853
- /* The user's text is DATA in the same message, never a system instruction: it
1854
- steers style (format, language, length) but cannot replace the rules above. */
1855
- const addition = typeof userPrompt === 'string' && userPrompt.trim() !== ''
1856
- ? '\n\nAdditional requirements from the user (style only; the rules above still apply):\n' + userPrompt.trim()
1857
- : ''
1858
- return { system: system, user: user + addition }
1859
- }
1860
-
1861
- /** Strip what a model habitually wraps a message in; keep the text itself. */
1862
- function cleanupCommitMessage(text) {
1863
- let out = String(text === null || text === undefined ? '' : text).trim()
1864
- const fence = /^```[a-zA-Z]*\n([\s\S]*?)\n?```$/.exec(out)
1865
- if (fence !== null) out = fence[1].trim()
1866
- if (out.length > 1 && ((out.startsWith('"') && out.endsWith('"')) || (out.startsWith('“') && out.endsWith('”')))) out = out.slice(1, -1).trim()
1867
- return out
1868
- }
1869
-
1870
- /* ---- ai commit message: the route handler ---- */
1871
- let commitServices = null
1872
- let commitBusy = false
1873
-
1874
- function gatewayError(code, message) {
1875
- return new PanelError(code, message, 502)
1876
- }
1877
-
1878
- function failureTextOf(failure) {
1879
- if (failure === null || failure === undefined) return ''
1880
- if (typeof failure.message === 'string' && failure.message !== '') return failure.message
1881
- return String(failure.code === undefined ? failure : failure.code)
1882
- }
1883
-
1884
- /** The no-route fallback: the first provider this host serves that lists at
1885
- least one model, and that provider's first model. Issue #19 — a fresh
1886
- session (no model picked yet) used to fail the panel button with 'no-route'
1887
- before the user ever saw a message; a commit message is too small a task to
1888
- lecture anyone about routing. Errors per provider stay silent on purpose:
1889
- a provider that fails its catalog simply is not a fallback candidate. */
1890
- async function fallbackRouteOf() {
1891
- const llm = commitServices.llm
1892
- for (const row of llm.listProviders()) {
1893
- try {
1894
- const models = await llm.listModels(row.id)
1895
- const model = models[0]
1896
- if (model !== undefined && model !== null && typeof model.id === 'string' && model.id !== '') {
1897
- return { provider: row.id, model: model.id }
1898
- }
1899
- } catch (error) {
1900
- void error
1901
- }
1902
- }
1903
- return null
1904
- }
1905
-
1906
- /** Route the call exactly like the session does when it talks to the model. */
1907
- async function commitRouteOf(payload) {
1908
- const wanted = typeof payload.model === 'string' ? payload.model.trim() : ''
1909
- if (wanted === '') {
1910
- const id = typeof payload.sessionId === 'string' ? payload.sessionId.trim() : ''
1911
- if (id === '') throw new PanelError('no-session', 'no session id: cannot follow the session model route', 502)
1912
- const list = commitServices.sessions.list()
1913
- const session = list.filter((row) => String(row.id) === id)[0]
1914
- if (session === undefined) throw new PanelError('no-session', 'unknown session "' + id + '"', 502)
1915
- let header = null
1916
- try { header = session.requestHeader() } catch (error) { header = null; void error }
1917
- const config = header === null || header === undefined || header.config === undefined || header.config === null ? {} : header.config
1918
- if (typeof config.provider !== 'string' || typeof config.model !== 'string') {
1919
- /* Issue #19: 'no-route' is the failure every blank session hit. The
1920
- served fallback below keeps the button useful on a brand-new session;
1921
- the panel's model picker (this release) is the user's visible way to
1922
- steer it, and it drives the SAME session route once a model is picked. */
1923
- const fallback = await fallbackRouteOf()
1924
- if (fallback !== null) return { provider: fallback.provider, model: fallback.model, source: 'fallback' }
1925
- throw new PanelError('no-models', 'no model route and no served models to fall back to; pick a model in the session first', 502)
1926
- }
1927
- return { provider: config.provider, model: config.model, source: 'session' }
1928
- }
1929
- const slash = wanted.indexOf('/')
1930
- if (slash <= 0 || slash === wanted.length - 1 || wanted.indexOf('/', slash + 1) >= 0) {
1931
- throw badRequest('model must look like "provider/model"')
1932
- }
1933
- const provider = wanted.slice(0, slash)
1934
- const model = wanted.slice(slash + 1)
1935
- const known = commitServices.llm.listProviders().map((row) => row.id)
1936
- if (known.indexOf(provider) < 0) {
1937
- throw new PanelError('unknown-provider', 'unknown provider "' + provider + '"; this host serves: ' + known.join(', '))
1938
- }
1939
- /* The catalog is advisory (routing accepts unlisted ids), so an empty catalog
1940
- cannot prove a model wrong — only a non-empty one without this id can. */
1941
- const models = await commitServices.llm.listModels(provider)
1942
- if (models.length > 0 && !models.some((row) => row.id === model)) {
1943
- throw new PanelError('unknown-model', 'unknown model "' + model + '" for ' + provider + '; try: ' + models.map((row) => row.id).slice(0, 8).join(', '))
1944
- }
1945
- return { provider: provider, model: model, source: 'setting' }
1946
- }
1947
-
1948
- /** The unset reasoning default: 'off' when this route offers it, '' otherwise.
1949
- Failures stay silent on purpose — this is a preference, not a contract: an
1950
- unreachable capability lookup must not turn a working model call into an
1951
- error; it just leaves the effort to the adapter's own default. */
1952
- async function quietOffEffortOf(route) {
1953
- try {
1954
- const llm = commitServices.llm
1955
- if (typeof llm.resolveModelInfo !== 'function') return ''
1956
- const info = await llm.resolveModelInfo(route.provider, route.model)
1957
- const reasoning = info !== null && typeof info === 'object' && info.reasoning !== null && typeof info.reasoning === 'object' ? info.reasoning : null
1958
- const efforts = reasoning !== null && Array.isArray(reasoning.efforts) ? reasoning.efforts : []
1959
- return efforts.some((row) => row !== null && typeof row === 'object' && row.id === 'off') ? 'off' : ''
1960
- } catch (error) {
1961
- void error
1962
- return ''
1963
- }
1964
- }
1965
-
1966
- /** The settings card's model picker: every provider route this host serves and
1967
- the models each one lists. The catalog is advisory (routing accepts
1968
- unlisted ids, see commitRouteOf), so a stored hand-typed value stays valid
1969
- even when it is not in this list. */
1970
- async function commitModels() {
1971
- if (commitServices === null) {
1972
- throw new PanelError('no-host-service', 'this host does not serve llm + sessions, so there is no model catalog here', 501)
1973
- }
1974
- const providers = []
1975
- for (const row of commitServices.llm.listProviders()) {
1976
- /* One provider's discovery failing must not hide the others: its entry
1977
- ships with an empty model list and the picker still works. */
1978
- let models = []
1979
- try { models = await commitServices.llm.listModels(row.id) } catch (error) { models = []; void error }
1980
- providers.push({
1981
- id: row.id,
1982
- name: typeof row.name === 'string' && row.name !== '' ? row.name : row.id,
1983
- models: models
1984
- .filter((model) => model !== null && typeof model === 'object' && typeof model.id === 'string' && model.id !== '')
1985
- .map((model) => ({ id: model.id, name: typeof model.name === 'string' && model.name !== '' ? model.name : model.id })),
1986
- })
1987
- }
1988
- return { providers: providers }
1989
- }
1990
-
1991
- /** The settings card's reasoning picker: the efforts one exact provider/model
1992
- route accepts. An unreachable capability lookup answers with an EMPTY list
1993
- (the browser falls back to its text field) instead of an error — the picker
1994
- is a convenience, not a gate. */
1995
- async function commitEfforts(payload) {
1996
- if (commitServices === null) {
1997
- throw new PanelError('no-host-service', 'this host does not serve llm + sessions, so there are no reasoning efforts here', 501)
1998
- }
1999
- const wanted = typeof payload.model === 'string' ? payload.model.trim() : ''
2000
- const slash = wanted.indexOf('/')
2001
- if (slash <= 0 || slash === wanted.length - 1 || wanted.indexOf('/', slash + 1) >= 0) {
2002
- throw badRequest('model must look like "provider/model"')
2003
- }
2004
- const provider = wanted.slice(0, slash)
2005
- const model = wanted.slice(slash + 1)
2006
- const known = commitServices.llm.listProviders().map((row) => row.id)
2007
- if (known.indexOf(provider) < 0) {
2008
- throw new PanelError('unknown-provider', 'unknown provider "' + provider + '"; this host serves: ' + known.join(', '))
2009
- }
2010
- if (typeof commitServices.llm.resolveModelInfo !== 'function') {
2011
- return { provider: provider, model: model, efforts: [], defaultEffort: null }
2012
- }
2013
- try {
2014
- const info = await commitServices.llm.resolveModelInfo(provider, model)
2015
- const reasoning = info !== null && typeof info === 'object' && info.reasoning !== null && typeof info.reasoning === 'object' ? info.reasoning : null
2016
- const efforts = reasoning !== null && Array.isArray(reasoning.efforts) ? reasoning.efforts : []
2017
- return {
2018
- provider: provider,
2019
- model: model,
2020
- efforts: efforts
2021
- .filter((row) => row !== null && typeof row === 'object' && typeof row.id === 'string' && row.id !== '')
2022
- .map((row) => ({ id: row.id, name: typeof row.name === 'string' && row.name !== '' ? row.name : row.id })),
2023
- defaultEffort: reasoning !== null && typeof reasoning.defaultEffort === 'string' ? reasoning.defaultEffort : null,
2024
- }
2025
- } catch (error) {
2026
- void error
2027
- return { provider: provider, model: model, efforts: [], defaultEffort: null }
2028
- }
2029
- }
2030
-
2031
- async function commitMessage(payload) {
2032
- if (commitServices === null) {
2033
- throw new PanelError('no-host-service', 'this host does not serve llm + sessions, so the message cannot be written here', 501)
2034
- }
2035
- /* The single-flight window opens BEFORE the first await and covers the route
2036
- lookup, the git reads and the model call. Claiming it only around the stream
2037
- let four simultaneous clicks all pass the check and spend four model calls
2038
- (measured: 4x200 on one click burst), which is four times the user's quota. */
2039
- if (commitBusy) throw new PanelError('busy', 'a commit message is already being written', 409)
2040
- commitBusy = true
2041
- try {
2042
- const cwd = cwdOf(payload)
2043
- /* The picked repository travels as `repoRoot`, and every other git call
2044
- threads it through `repoRootOf`. Resolving the root from `cwd` alone made
2045
- this the one route that answered not-a-repo in a workspace whose own root
2046
- is not a repository — a workspace holding several sibling repos, which is
2047
- exactly what the repo picker exists for. */
2048
- const repoRoot = await repoRootOf(cwd, payload)
2049
- const scope = { cwd: cwd, repoRoot: repoRoot }
2050
- const route = await commitRouteOf(payload)
2051
- const sums = await summary(scope)
2052
- const stagedPatch = sums.changes.staged.length > 0 ? (await diff(Object.assign({}, scope, { staged: true }))).patch : ''
2053
- const input = commitInputOf(stagedPatch, (await diff(scope)).patch)
2054
- if (input.files.length === 0) {
2055
- if (input.dropped > 0) {
2056
- throw new PanelError('only-ignored-changes', 'the only changes are lock files or binary files, which are not sent to the model; stage a source change first')
2057
- }
2058
- throw badRequest('nothing to describe: stage or edit something first')
2059
- }
2060
- const asked = typeof payload.prompt === 'string' ? payload.prompt : ''
2061
- const prompt = asked.slice(0, COMMIT_MAX_PROMPT_CHARS)
2062
- const reasoning = typeof payload.reasoningEffort === 'string' ? payload.reasoningEffort.trim() : ''
2063
- const spoken = commitMessagesOf(input, prompt)
2064
- const options = {
2065
- provider: route.provider,
2066
- model: route.model,
2067
- system: spoken.system,
2068
- messages: [{ role: 'user', content: [{ type: 'text', text: spoken.user }] }],
2069
- temperature: 0.2,
2070
- }
2071
- /* No maxTokens on purpose: omitted, the runtime materializes the selected
2072
- model's own configured output cap (resolveModelInfo().defaultMaxTokens) —
2073
- exactly what a session call gets. The hardcoded 512 here was half of
2074
- issue #7: with thinking enabled, max_tokens is the budget for reasoning
2075
- AND message together, so 512 died inside the reasoning phase before one
2076
- word of the message existed. */
2077
- /* The other half of issue #7: an UNSET effort is not a neutral "model
2078
- default" — the DeepSeek adapter resolves "unspecified" to reasoning ON at
2079
- "high". A commit message is a formatting task, so the unset default now
2080
- asks for "off" whenever the route offers it. The id is read from the
2081
- route's own efforts table (resolveModelInfo), never assumed: the runtime
2082
- rejects an effort the route does not list, so a blind 'off' would trade
2083
- one loud failure for another. The user's explicit choice always wins. */
2084
- const effectiveReasoning = reasoning !== '' ? reasoning : await quietOffEffortOf(route)
2085
- if (effectiveReasoning !== '') options.reasoningEffort = effectiveReasoning
2086
- if (typeof payload.sessionId === 'string' && payload.sessionId !== '') options.sessionId = payload.sessionId
2087
- const started = Date.now()
2088
- const collected = await collectCommitStream(commitServices.llm.stream(options))
2089
- /* An adapter failure is a terminal finish, not a throw: a missing check here
2090
- would turn "the call failed" into "the model said nothing" (假成功). */
2091
- const finish = collected.finish
2092
- if (finish === null || finish === undefined) {
2093
- throw gatewayError('llm-no-finish', 'the model stream ended without a finish reason; treat the result as unknown, not as success')
2094
- }
2095
- if (finish.kind === 'error') throw gatewayError('llm-error', failureTextOf(finish.failure))
2096
- if (finish.kind === 'aborted') throw gatewayError('llm-aborted', 'the call was aborted: ' + failureTextOf(finish.failure))
2097
- if (finish.kind !== 'stop') {
2098
- /* The max-tokens + no-text + reasoning-delta shape is the exact signature
2099
- of issue #7: the budget (reasoning included) ran out mid-thought. Say
2100
- what happened and what to do, instead of a bare finish kind. */
2101
- if (finish.kind === 'max-tokens' && collected.text.trim() === '' && collected.sawReasoning === true) {
2102
- throw gatewayError('llm-unfinished', 'the model stopped with "max-tokens" before writing the message: its reasoning spent the whole output budget; lower the reasoning effort in the plugin settings or pick a lighter model')
2103
- }
2104
- throw gatewayError('llm-unfinished', 'the model stopped with "' + String(finish.kind) + '"; the message may be incomplete')
2105
- }
2106
- const message = cleanupCommitMessage(collected.text)
2107
- if (message === '') throw gatewayError('empty-output', 'the model returned no text')
2108
- return {
2109
- message: message,
2110
- provider: route.provider,
2111
- model: route.model,
2112
- routeSource: route.source,
2113
- reasoningEffort: effectiveReasoning === '' ? null : effectiveReasoning,
2114
- elapsedMs: Date.now() - started,
2115
- input: { files: input.files.length, bytes: input.bytes, dropped: input.dropped, truncated: input.truncated },
2116
- promptTruncated: asked.length > COMMIT_MAX_PROMPT_CHARS,
2117
- }
2118
- } finally {
2119
- commitBusy = false
2120
- }
2121
- }
2122
-
2123
- const METHODS = {
2124
- version,
2125
- 'commit-message': commitMessage,
2126
- 'commit-models': commitModels,
2127
- 'commit-efforts': commitEfforts,
2128
- repos,
2129
- summary,
2130
- stats,
2131
- branches,
2132
- log,
2133
- me,
2134
- 'merge-point': mergePoint,
2135
- commitDetail,
2136
- diff,
2137
- compare,
2138
- stage,
2139
- unstage,
2140
- discard,
2141
- commit,
2142
- checkout,
2143
- branchCreate,
2144
- branchRename,
2145
- 'branch-upstream': branchUpstream,
2146
- branchDelete,
2147
- worktreeAdd,
2148
- worktreeRemove,
2149
- worktreePrune,
2150
- merge,
2151
- rebase,
2152
- cherryPick,
2153
- revert,
2154
- reset,
2155
- fetch,
2156
- pull,
2157
- push,
2158
- stashList,
2159
- stashPush,
2160
- stashApply,
2161
- stashDrop,
2162
- tagCreate,
2163
- tagDelete,
2164
- tagDeleteRemote,
2165
- pushTags,
2166
- clone,
2167
- gitLogs,
2168
- remoteAdd,
2169
- remoteRemove,
2170
- branchDeleteRemote,
2171
- rebaseAbort,
2172
- stashClear,
2173
- undoList,
2174
- undoApply,
2175
- }
2176
-
2177
- /* ============================== http plumbing ============================== */
2178
-
2179
- function headerOf(req, name) {
2180
- const value = req.headers[name]
2181
- if (Array.isArray(value)) return value[0]
2182
- return value
2183
- }
2184
-
2185
- /* Normalize an authority ("host[:port]" or an absolute URL) to
2186
- "hostname:port", filling the scheme default port when it is implicit.
2187
- Returns '' for anything unparseable (including the "null" opaque origin). */
2188
- function authorityKey(value, protocol) {
2189
- const text = String(value === undefined || value === null ? '' : value).trim()
2190
- if (text === '') return ''
2191
- let hostname = text
2192
- let port = ''
2193
- let scheme = protocol
2194
- if (text.includes('://') === true) {
2195
- let parsed = null
2196
- try { parsed = new URL(text) } catch (error) { parsed = null }
2197
- if (parsed === null) return ''
2198
- hostname = parsed.hostname
2199
- port = parsed.port
2200
- scheme = parsed.protocol
2201
- } else {
2202
- const bracketed = /^\[([^\]]+)\](?::(\d+))?$/.exec(text)
2203
- if (bracketed !== null) {
2204
- hostname = bracketed[1]
2205
- port = bracketed[2] === undefined ? '' : bracketed[2]
2206
- } else {
2207
- const plain = /^([^:]+)(?::(\d+))?$/.exec(text)
2208
- if (plain === null) return ''
2209
- hostname = plain[1]
2210
- port = plain[2] === undefined ? '' : plain[2]
2211
- }
2212
- }
2213
- if (hostname === '') return ''
2214
- if (port === '') port = scheme === 'https:' ? '443' : '80'
2215
- return hostname.toLowerCase() + ':' + port
2216
- }
2217
-
2218
- /* Same-origin fallback for browsers that send no Sec-Fetch-* headers. A page
2219
- served over plain HTTP from a non-loopback address is not a secure context,
2220
- and the fetch-metadata headers are omitted there (issue #20: the git panel
2221
- answered 403 on a LAN address). Origin and Referer are forbidden headers a
2222
- page cannot forge — a cross-site request always carries the caller's own
2223
- origin — so comparing either against the Host header restores the
2224
- same-origin fence without trusting anything the request body claims. */
2225
- function sameOriginOf(req, host) {
2226
- const hostKey = authorityKey(host, 'http:')
2227
- if (hostKey === '') return false
2228
- /* A Host header without a port says nothing about the scheme, so either
2229
- scheme default may be the one the browser used in Origin. */
2230
- const candidates = new Set([hostKey])
2231
- if (/:\d+$/.test(String(host).trim()) !== true) candidates.add(authorityKey(host, 'https:'))
2232
- const matches = (value) => {
2233
- const key = authorityKey(value, 'http:')
2234
- return key !== '' && candidates.has(key)
2235
- }
2236
- if (matches(headerOf(req, 'origin')) === true) return true
2237
- return matches(headerOf(req, 'referer'))
2238
- }
2239
-
2240
- /** Loopback authority, or a same-origin browser request (desktop shell / remote UI). */
2241
- function isTrusted(req) {
2242
- const host = headerOf(req, 'host')
2243
- if (host === undefined || host === '') return false
2244
- const hostname = host.replace(/^\[/, '').replace(/\]:\d+$/, '').replace(/:\d+$/, '')
2245
- if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '0.0.0.0') return true
2246
- if (/^127\./.test(hostname)) return true
2247
- const site = headerOf(req, 'sec-fetch-site')
2248
- if (site === 'same-origin' || site === 'same-site') return true
2249
- return sameOriginOf(req, host)
2250
- }
2251
-
2252
- function sendJson(res, status, body) {
2253
- const text = JSON.stringify(body)
2254
- res.writeHead(status, {
2255
- 'content-type': 'application/json; charset=utf-8',
2256
- 'cache-control': 'no-store',
2257
- 'content-length': Buffer.byteLength(text),
2258
- })
2259
- res.end(text)
2260
- }
2261
-
2262
- function readJsonBody(req, limit = 1 << 20) {
2263
- return new Promise((resolve, reject) => {
2264
- const chunks = []
2265
- let size = 0
2266
- req.on('data', (chunk) => {
2267
- size += chunk.length
2268
- if (size > limit) {
2269
- reject(new PanelError('too-large', 'request body is too large', 413))
2270
- req.destroy()
2271
- return
2272
- }
2273
- chunks.push(chunk)
2274
- })
2275
- req.on('end', () => {
2276
- const raw = Buffer.concat(chunks).toString('utf8')
2277
- if (raw.trim() === '') { resolve({}); return }
2278
- try {
2279
- const parsed = JSON.parse(raw)
2280
- resolve(parsed !== null && typeof parsed === 'object' ? parsed : {})
2281
- } catch {
2282
- reject(badRequest('request body must be JSON'))
2283
- }
2284
- })
2285
- req.on('error', (error) => { reject(new PanelError('transport', messageOf(error), 500)) })
2286
- })
2287
- }
2288
-
2289
- async function handle(req, res) {
2290
- if (!isTrusted(req)) {
2291
- sendJson(res, 403, {
2292
- ok: false,
2293
- error: {
2294
- code: 'forbidden',
2295
- message: 'forbidden: a non-loopback request must be same-origin (open this host from its own page) or come through the remote pairing proxy',
2296
- },
2297
- })
2298
- return
2299
- }
2300
- if (req.method !== 'POST') {
2301
- sendJson(res, 405, { ok: false, error: { code: 'method-error', message: 'POST required' } })
2302
- return
2303
- }
2304
- const pathname = new URL(req.url === undefined ? '/' : req.url, 'http://dsh.internal').pathname
2305
- const method = pathname.startsWith(ROUTE_PREFIX + '/') ? pathname.slice(ROUTE_PREFIX.length + 1) : ''
2306
- const handler = Object.prototype.hasOwnProperty.call(METHODS, method) ? METHODS[method] : undefined
2307
- if (handler === undefined || method.includes('/')) {
2308
- sendJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method "' + method + '"' } })
2309
- return
2310
- }
2311
- try {
2312
- const payload = await readJsonBody(req)
2313
- const data = WRITE_METHODS.has(method)
2314
- ? await withRepoLock(lockKeyOf(payload), () => handler(payload))
2315
- : await handler(payload)
2316
- sendJson(res, 200, { ok: true, data: data })
2317
- } catch (error) {
2318
- if (error instanceof PanelError) {
2319
- sendJson(res, error.status, { ok: false, error: { code: error.code, message: error.message } })
2320
- return
2321
- }
2322
- const message = messageOf(error)
2323
- sendJson(res, 500, { ok: false, error: { code: 'internal', message } })
2324
- }
2325
- }
2326
-
2327
18
  export function apply(ctx) {
2328
19
  ctx.effect(
2329
20
  () => ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler: handle }),
@@ -2338,8 +29,8 @@ export function apply(ctx) {
2338
29
  const sessions = injected.get('sessions')
2339
30
  if (llm === undefined || llm === null || sessions === undefined || sessions === null) return
2340
31
  injected.effect(() => {
2341
- commitServices = { llm: llm, sessions: sessions }
2342
- return () => { commitServices = null }
32
+ setCommitServices({ llm: llm, sessions: sessions })
33
+ return () => { setCommitServices(null) }
2343
34
  }, 'dsh-ide-git: ai commit message')
2344
35
  })
2345
36
  }