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