dsh-ide-git 0.23.0 → 0.25.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.
@@ -0,0 +1,354 @@
1
+ /**
2
+ * dsh-ide-git host: AI commit messages (issue #6). The llm + sessions services
3
+ * are injected by the plugin entry; this module never reaches for them itself.
4
+ */
5
+ import { PanelError, badRequest, cwdOf } from './core.js'
6
+ import { repoRootOf } from './git.js'
7
+ import { summary, diff } from './read.js'
8
+
9
+ /* ============================== ai commit message ============================== */
10
+ /* Issue #6: write the commit message with the session's own model route. The
11
+ settings arrive WITH the request (the browser reads them from the same row
12
+ Config document it renders), so a setting change takes effect on the next
13
+ click without the host caching anything. */
14
+ const COMMIT_MAX_PROMPT_CHARS = 2000
15
+ const COMMIT_TOTAL_BYTES = 12 * 1024
16
+ const COMMIT_FILE_BYTES = 2048
17
+ const COMMIT_FILE_LINES = 80
18
+ const COMMIT_MAX_FILES = 30
19
+ 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
20
+
21
+ /** One model call's text, plus how it ENDED (a failure arrives as a finish chunk). */
22
+ async function collectCommitStream(stream) {
23
+ let text = ''
24
+ let finish = null
25
+ let sawReasoning = false
26
+ for await (const chunk of stream) {
27
+ if (chunk === null || chunk === undefined) continue
28
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
29
+ else if (chunk.type === 'reasoning-delta') sawReasoning = true
30
+ else if (chunk.type === 'finish') finish = chunk.reason
31
+ }
32
+ return { text: text, finish: finish, sawReasoning: sawReasoning }
33
+ }
34
+
35
+ /** The change set, trimmed to a budget the model can actually be asked about. */
36
+ function commitInputOf(stagedPatch, unstagedPatch) {
37
+ const patch = (stagedPatch === '' ? '' : stagedPatch) + (unstagedPatch === '' ? '' : (stagedPatch === '' ? '' : '\n') + unstagedPatch)
38
+ const files = []
39
+ let bytes = 0
40
+ let dropped = 0
41
+ let truncated = false
42
+ const parts = patch.split(/^diff --git /m).slice(1)
43
+ for (const part of parts) {
44
+ const head = /^a\/(.+?) b\//.exec(part)
45
+ const name = head === null ? '(unknown)' : head[1]
46
+ if (files.length >= COMMIT_MAX_FILES) { dropped += 1; continue }
47
+ if (COMMIT_BINARY.test(name)) { dropped += 1; continue }
48
+ if (/(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|composer\.lock)$/.test(name)) { dropped += 1; continue }
49
+ let body = 'diff --git ' + part
50
+ let trimmed = false
51
+ const lines = body.split('\n')
52
+ if (lines.length > COMMIT_FILE_LINES) { body = lines.slice(0, COMMIT_FILE_LINES).join('\n'); trimmed = true }
53
+ if (body.length > COMMIT_FILE_BYTES) { body = body.slice(0, COMMIT_FILE_BYTES); trimmed = true }
54
+ /* A file the total budget then drops was never sent, so it must not report
55
+ as "truncated": truncated means "the model saw a shortened version". */
56
+ if (bytes + body.length > COMMIT_TOTAL_BYTES) { dropped += 1; continue }
57
+ if (trimmed === true) truncated = true
58
+ bytes += body.length
59
+ files.push({ path: name, body: body })
60
+ }
61
+ return { files: files, bytes: bytes, dropped: dropped, truncated: truncated }
62
+ }
63
+
64
+ /** Built-in instruction + the change set + the user's own addition, as DATA. */
65
+ function commitMessagesOf(input, userPrompt) {
66
+ const system = [
67
+ 'You write git commit messages.',
68
+ 'Reply with the commit message ONLY: no prose, no code fences, no quotes, no explanation.',
69
+ 'First line: a concise subject, at most 72 characters, no trailing period.',
70
+ 'Then one blank line and a short body explaining what changed and why, when it adds value.',
71
+ 'Describe only what the diff shows. Never invent files, features or issue numbers.',
72
+ ].join('\n')
73
+ const user = [
74
+ 'Write the commit message for these changes.',
75
+ '',
76
+ input.files.map((file) => file.body).join('\n'),
77
+ ].join('\n')
78
+ /* The user's text is DATA in the same message, never a system instruction: it
79
+ steers style (format, language, length) but cannot replace the rules above. */
80
+ const addition = typeof userPrompt === 'string' && userPrompt.trim() !== ''
81
+ ? '\n\nAdditional requirements from the user (style only; the rules above still apply):\n' + userPrompt.trim()
82
+ : ''
83
+ return { system: system, user: user + addition }
84
+ }
85
+
86
+ /** Strip what a model habitually wraps a message in; keep the text itself. */
87
+ function cleanupCommitMessage(text) {
88
+ let out = String(text === null || text === undefined ? '' : text).trim()
89
+ const fence = /^```[a-zA-Z]*\n([\s\S]*?)\n?```$/.exec(out)
90
+ if (fence !== null) out = fence[1].trim()
91
+ if (out.length > 1 && ((out.startsWith('"') && out.endsWith('"')) || (out.startsWith('“') && out.endsWith('”')))) out = out.slice(1, -1).trim()
92
+ return out
93
+ }
94
+
95
+ /* ---- ai commit message: the route handler ---- */
96
+ let commitServices = null
97
+ let commitBusy = false
98
+
99
+ function gatewayError(code, message) {
100
+ return new PanelError(code, message, 502)
101
+ }
102
+
103
+ function failureTextOf(failure) {
104
+ if (failure === null || failure === undefined) return ''
105
+ if (typeof failure.message === 'string' && failure.message !== '') return failure.message
106
+ return String(failure.code === undefined ? failure : failure.code)
107
+ }
108
+
109
+ /** The no-route fallback: the first provider this host serves that lists at
110
+ least one model, and that provider's first model. Issue #19 — a fresh
111
+ session (no model picked yet) used to fail the panel button with 'no-route'
112
+ before the user ever saw a message; a commit message is too small a task to
113
+ lecture anyone about routing. Errors per provider stay silent on purpose:
114
+ a provider that fails its catalog simply is not a fallback candidate. */
115
+ async function fallbackRouteOf() {
116
+ const llm = commitServices.llm
117
+ for (const row of llm.listProviders()) {
118
+ try {
119
+ const models = await llm.listModels(row.id)
120
+ const model = models[0]
121
+ if (model !== undefined && model !== null && typeof model.id === 'string' && model.id !== '') {
122
+ return { provider: row.id, model: model.id }
123
+ }
124
+ } catch (error) {
125
+ void error
126
+ }
127
+ }
128
+ return null
129
+ }
130
+
131
+ /** Route the call exactly like the session does when it talks to the model. */
132
+ async function commitRouteOf(payload) {
133
+ const wanted = typeof payload.model === 'string' ? payload.model.trim() : ''
134
+ if (wanted === '') {
135
+ const id = typeof payload.sessionId === 'string' ? payload.sessionId.trim() : ''
136
+ if (id === '') throw new PanelError('no-session', 'no session id: cannot follow the session model route', 502)
137
+ const list = commitServices.sessions.list()
138
+ const session = list.filter((row) => String(row.id) === id)[0]
139
+ if (session === undefined) throw new PanelError('no-session', 'unknown session "' + id + '"', 502)
140
+ let header = null
141
+ try { header = session.requestHeader() } catch (error) { header = null; void error }
142
+ const config = header === null || header === undefined || header.config === undefined || header.config === null ? {} : header.config
143
+ if (typeof config.provider !== 'string' || typeof config.model !== 'string') {
144
+ /* Issue #19: 'no-route' is the failure every blank session hit. The
145
+ served fallback below keeps the button useful on a brand-new session;
146
+ the panel's model picker (this release) is the user's visible way to
147
+ steer it, and it drives the SAME session route once a model is picked. */
148
+ const fallback = await fallbackRouteOf()
149
+ if (fallback !== null) return { provider: fallback.provider, model: fallback.model, source: 'fallback' }
150
+ throw new PanelError('no-models', 'no model route and no served models to fall back to; pick a model in the session first', 502)
151
+ }
152
+ return { provider: config.provider, model: config.model, source: 'session' }
153
+ }
154
+ const slash = wanted.indexOf('/')
155
+ if (slash <= 0 || slash === wanted.length - 1 || wanted.indexOf('/', slash + 1) >= 0) {
156
+ throw badRequest('model must look like "provider/model"')
157
+ }
158
+ const provider = wanted.slice(0, slash)
159
+ const model = wanted.slice(slash + 1)
160
+ const known = commitServices.llm.listProviders().map((row) => row.id)
161
+ if (known.indexOf(provider) < 0) {
162
+ throw new PanelError('unknown-provider', 'unknown provider "' + provider + '"; this host serves: ' + known.join(', '))
163
+ }
164
+ /* The catalog is advisory (routing accepts unlisted ids), so an empty catalog
165
+ cannot prove a model wrong — only a non-empty one without this id can. */
166
+ const models = await commitServices.llm.listModels(provider)
167
+ if (models.length > 0 && !models.some((row) => row.id === model)) {
168
+ throw new PanelError('unknown-model', 'unknown model "' + model + '" for ' + provider + '; try: ' + models.map((row) => row.id).slice(0, 8).join(', '))
169
+ }
170
+ return { provider: provider, model: model, source: 'setting' }
171
+ }
172
+
173
+ /** The unset reasoning default: 'off' when this route offers it, '' otherwise.
174
+ Failures stay silent on purpose — this is a preference, not a contract: an
175
+ unreachable capability lookup must not turn a working model call into an
176
+ error; it just leaves the effort to the adapter's own default. */
177
+ async function quietOffEffortOf(route) {
178
+ try {
179
+ const llm = commitServices.llm
180
+ if (typeof llm.resolveModelInfo !== 'function') return ''
181
+ const info = await llm.resolveModelInfo(route.provider, route.model)
182
+ const reasoning = info !== null && typeof info === 'object' && info.reasoning !== null && typeof info.reasoning === 'object' ? info.reasoning : null
183
+ const efforts = reasoning !== null && Array.isArray(reasoning.efforts) ? reasoning.efforts : []
184
+ return efforts.some((row) => row !== null && typeof row === 'object' && row.id === 'off') ? 'off' : ''
185
+ } catch (error) {
186
+ void error
187
+ return ''
188
+ }
189
+ }
190
+
191
+ /** The settings card's model picker: every provider route this host serves and
192
+ the models each one lists. The catalog is advisory (routing accepts
193
+ unlisted ids, see commitRouteOf), so a stored hand-typed value stays valid
194
+ even when it is not in this list. */
195
+ async function commitModels() {
196
+ if (commitServices === null) {
197
+ throw new PanelError('no-host-service', 'this host does not serve llm + sessions, so there is no model catalog here', 501)
198
+ }
199
+ const providers = []
200
+ for (const row of commitServices.llm.listProviders()) {
201
+ /* One provider's discovery failing must not hide the others: its entry
202
+ ships with an empty model list and the picker still works. */
203
+ let models = []
204
+ try { models = await commitServices.llm.listModels(row.id) } catch (error) { models = []; void error }
205
+ providers.push({
206
+ id: row.id,
207
+ name: typeof row.name === 'string' && row.name !== '' ? row.name : row.id,
208
+ models: models
209
+ .filter((model) => model !== null && typeof model === 'object' && typeof model.id === 'string' && model.id !== '')
210
+ .map((model) => ({ id: model.id, name: typeof model.name === 'string' && model.name !== '' ? model.name : model.id })),
211
+ })
212
+ }
213
+ return { providers: providers }
214
+ }
215
+
216
+ /** The settings card's reasoning picker: the efforts one exact provider/model
217
+ route accepts. An unreachable capability lookup answers with an EMPTY list
218
+ (the browser falls back to its text field) instead of an error — the picker
219
+ is a convenience, not a gate. */
220
+ async function commitEfforts(payload) {
221
+ if (commitServices === null) {
222
+ throw new PanelError('no-host-service', 'this host does not serve llm + sessions, so there are no reasoning efforts here', 501)
223
+ }
224
+ const wanted = typeof payload.model === 'string' ? payload.model.trim() : ''
225
+ const slash = wanted.indexOf('/')
226
+ if (slash <= 0 || slash === wanted.length - 1 || wanted.indexOf('/', slash + 1) >= 0) {
227
+ throw badRequest('model must look like "provider/model"')
228
+ }
229
+ const provider = wanted.slice(0, slash)
230
+ const model = wanted.slice(slash + 1)
231
+ const known = commitServices.llm.listProviders().map((row) => row.id)
232
+ if (known.indexOf(provider) < 0) {
233
+ throw new PanelError('unknown-provider', 'unknown provider "' + provider + '"; this host serves: ' + known.join(', '))
234
+ }
235
+ if (typeof commitServices.llm.resolveModelInfo !== 'function') {
236
+ return { provider: provider, model: model, efforts: [], defaultEffort: null }
237
+ }
238
+ try {
239
+ const info = await commitServices.llm.resolveModelInfo(provider, model)
240
+ const reasoning = info !== null && typeof info === 'object' && info.reasoning !== null && typeof info.reasoning === 'object' ? info.reasoning : null
241
+ const efforts = reasoning !== null && Array.isArray(reasoning.efforts) ? reasoning.efforts : []
242
+ return {
243
+ provider: provider,
244
+ model: model,
245
+ efforts: efforts
246
+ .filter((row) => row !== null && typeof row === 'object' && typeof row.id === 'string' && row.id !== '')
247
+ .map((row) => ({ id: row.id, name: typeof row.name === 'string' && row.name !== '' ? row.name : row.id })),
248
+ defaultEffort: reasoning !== null && typeof reasoning.defaultEffort === 'string' ? reasoning.defaultEffort : null,
249
+ }
250
+ } catch (error) {
251
+ void error
252
+ return { provider: provider, model: model, efforts: [], defaultEffort: null }
253
+ }
254
+ }
255
+
256
+ async function commitMessage(payload) {
257
+ if (commitServices === null) {
258
+ throw new PanelError('no-host-service', 'this host does not serve llm + sessions, so the message cannot be written here', 501)
259
+ }
260
+ /* The single-flight window opens BEFORE the first await and covers the route
261
+ lookup, the git reads and the model call. Claiming it only around the stream
262
+ let four simultaneous clicks all pass the check and spend four model calls
263
+ (measured: 4x200 on one click burst), which is four times the user's quota. */
264
+ if (commitBusy) throw new PanelError('busy', 'a commit message is already being written', 409)
265
+ commitBusy = true
266
+ try {
267
+ const cwd = cwdOf(payload)
268
+ /* The picked repository travels as `repoRoot`, and every other git call
269
+ threads it through `repoRootOf`. Resolving the root from `cwd` alone made
270
+ this the one route that answered not-a-repo in a workspace whose own root
271
+ is not a repository — a workspace holding several sibling repos, which is
272
+ exactly what the repo picker exists for. */
273
+ const repoRoot = await repoRootOf(cwd, payload)
274
+ const scope = { cwd: cwd, repoRoot: repoRoot }
275
+ const route = await commitRouteOf(payload)
276
+ const sums = await summary(scope)
277
+ const stagedPatch = sums.changes.staged.length > 0 ? (await diff(Object.assign({}, scope, { staged: true }))).patch : ''
278
+ const input = commitInputOf(stagedPatch, (await diff(scope)).patch)
279
+ if (input.files.length === 0) {
280
+ if (input.dropped > 0) {
281
+ 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')
282
+ }
283
+ throw badRequest('nothing to describe: stage or edit something first')
284
+ }
285
+ const asked = typeof payload.prompt === 'string' ? payload.prompt : ''
286
+ const prompt = asked.slice(0, COMMIT_MAX_PROMPT_CHARS)
287
+ const reasoning = typeof payload.reasoningEffort === 'string' ? payload.reasoningEffort.trim() : ''
288
+ const spoken = commitMessagesOf(input, prompt)
289
+ const options = {
290
+ provider: route.provider,
291
+ model: route.model,
292
+ system: spoken.system,
293
+ messages: [{ role: 'user', content: [{ type: 'text', text: spoken.user }] }],
294
+ temperature: 0.2,
295
+ }
296
+ /* No maxTokens on purpose: omitted, the runtime materializes the selected
297
+ model's own configured output cap (resolveModelInfo().defaultMaxTokens) —
298
+ exactly what a session call gets. The hardcoded 512 here was half of
299
+ issue #7: with thinking enabled, max_tokens is the budget for reasoning
300
+ AND message together, so 512 died inside the reasoning phase before one
301
+ word of the message existed. */
302
+ /* The other half of issue #7: an UNSET effort is not a neutral "model
303
+ default" — the DeepSeek adapter resolves "unspecified" to reasoning ON at
304
+ "high". A commit message is a formatting task, so the unset default now
305
+ asks for "off" whenever the route offers it. The id is read from the
306
+ route's own efforts table (resolveModelInfo), never assumed: the runtime
307
+ rejects an effort the route does not list, so a blind 'off' would trade
308
+ one loud failure for another. The user's explicit choice always wins. */
309
+ const effectiveReasoning = reasoning !== '' ? reasoning : await quietOffEffortOf(route)
310
+ if (effectiveReasoning !== '') options.reasoningEffort = effectiveReasoning
311
+ if (typeof payload.sessionId === 'string' && payload.sessionId !== '') options.sessionId = payload.sessionId
312
+ const started = Date.now()
313
+ const collected = await collectCommitStream(commitServices.llm.stream(options))
314
+ /* An adapter failure is a terminal finish, not a throw: a missing check here
315
+ would turn "the call failed" into "the model said nothing" (假成功). */
316
+ const finish = collected.finish
317
+ if (finish === null || finish === undefined) {
318
+ throw gatewayError('llm-no-finish', 'the model stream ended without a finish reason; treat the result as unknown, not as success')
319
+ }
320
+ if (finish.kind === 'error') throw gatewayError('llm-error', failureTextOf(finish.failure))
321
+ if (finish.kind === 'aborted') throw gatewayError('llm-aborted', 'the call was aborted: ' + failureTextOf(finish.failure))
322
+ if (finish.kind !== 'stop') {
323
+ /* The max-tokens + no-text + reasoning-delta shape is the exact signature
324
+ of issue #7: the budget (reasoning included) ran out mid-thought. Say
325
+ what happened and what to do, instead of a bare finish kind. */
326
+ if (finish.kind === 'max-tokens' && collected.text.trim() === '' && collected.sawReasoning === true) {
327
+ 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')
328
+ }
329
+ throw gatewayError('llm-unfinished', 'the model stopped with "' + String(finish.kind) + '"; the message may be incomplete')
330
+ }
331
+ const message = cleanupCommitMessage(collected.text)
332
+ if (message === '') throw gatewayError('empty-output', 'the model returned no text')
333
+ return {
334
+ message: message,
335
+ provider: route.provider,
336
+ model: route.model,
337
+ routeSource: route.source,
338
+ reasoningEffort: effectiveReasoning === '' ? null : effectiveReasoning,
339
+ elapsedMs: Date.now() - started,
340
+ input: { files: input.files.length, bytes: input.bytes, dropped: input.dropped, truncated: input.truncated },
341
+ promptTruncated: asked.length > COMMIT_MAX_PROMPT_CHARS,
342
+ }
343
+ } finally {
344
+ commitBusy = false
345
+ }
346
+ }
347
+
348
+ export function setCommitServices(services) {
349
+
350
+ commitServices = services
351
+
352
+ }
353
+
354
+ export { commitMessage, commitModels, commitEfforts }
@@ -0,0 +1,71 @@
1
+ /** dsh-ide-git host: row Config (rail switches + commit-message settings). */
2
+
3
+ /* Row Config = the plugin's settings surface on dsh >= 0.1.7: the host persists
4
+ these values under the loader row id ('ide-git', see cordis.patch.yml) and the
5
+ browser reads them through ctx.configForms, so the settings card on the
6
+ plugin's page and the action rail read ONE document. The rail's edit is one
7
+ switch per action (`railRefresh` … `railPush`); a missing field means "default"
8
+ (the action is shown), which is what keeps an action added by a later version
9
+ visible on an older profile.
10
+ `@deepseek-ai/schemastery` is resolved LAZILY: it is a host-provided module
11
+ (every sibling plugin declares its row Config the same way), and on a host that
12
+ cannot resolve it the row config is passed through unvalidated instead of the
13
+ whole row disappearing — so the browser keeps its localStorage home there. */
14
+ let Schema = null
15
+ try {
16
+ Schema = (await import('@deepseek-ai/schemastery')).default
17
+ } catch (error) {
18
+ console.warn('[dsh-ide-git] @deepseek-ai/schemastery is not resolvable here; the row Config surface is absent'
19
+ + ' (the browser falls back to its localStorage rail config): ' + (error && error.message ? error.message : String(error)))
20
+ }
21
+
22
+ /** Rail action ids, in rail order. MUST mirror RAIL_SPECS in src/client.js. */
23
+ const RAIL_CONFIG_IDS = [
24
+ 'refresh', 'tree', 'worktrees', 'float', 'split', 'fullscreen', 'newBranch', 'checkout', 'delete',
25
+ 'compare', 'diff', 'stash', 'tag', 'favorite', 'fetch', 'pull', 'push',
26
+ ]
27
+
28
+ /** Volatile refs (dsh >= 0.1.7 settings contract) or plain values on older hosts. */
29
+ function live(schema) {
30
+ return typeof schema.volatile === 'function' ? schema.volatile() : schema
31
+ }
32
+
33
+ function railConfigSchema(schema) {
34
+ const shape = {}
35
+ for (const id of RAIL_CONFIG_IDS) {
36
+ shape['rail' + id.charAt(0).toUpperCase() + id.slice(1)] = live(schema.boolean().default(true))
37
+ }
38
+ /* AI commit message (issue #6), the three settings the user asked for. Empty
39
+ string means "follow whatever the session is using" / "no extra prompt", so
40
+ an untouched profile behaves exactly like the feature's default. */
41
+ shape.commitModel = live(schema.string().default(''))
42
+ shape.commitReasoning = live(schema.string().default(''))
43
+ shape.commitPrompt = live(schema.string().default(''))
44
+ /* Conversation-area view tab (issue #8): on by default, switchable off from
45
+ the plugin settings card. false means "do not register the
46
+ conversation.view seat at all" — the sidebar and dock seats are not
47
+ affected; a missing field means the default (shown). */
48
+ shape.conversationTab = live(schema.boolean().default(true))
49
+ /* Immersive conversation seat (issue #8 round 3): on by default. false means
50
+ the seat falls back to the pin-to-scrollport layout with a visible
51
+ composer; a missing field means the default (on). NOTE: the field MUST be
52
+ declared here — the Host rejects row-Config writes for paths the schema
53
+ does not mark volatile ("Config field … is not volatile"), and the settings
54
+ switch then rolls back silently. */
55
+ shape.immersive = live(schema.boolean().default(true))
56
+ /* Composer status pill (v0.19.0): the settings card's "输入框 Git 状态" switch
57
+ and the pill itself read this field. v0.19.0-v0.19.6 shipped the switch and
58
+ the reader WITHOUT this declaration, so the Host rejected every write with
59
+ `Config field "composerStatus" is not volatile` — the card rolled the switch
60
+ back and said "设置没写进去". Any new row-Config field the client writes MUST
61
+ be declared (and volatile) here, or the write is refused before it lands. */
62
+ shape.composerStatus = live(schema.boolean().default(true))
63
+ /* Inline diff preview (issue #22): ON by default so the panel's own diff
64
+ pane — with its split / wrap switches — stays reachable even when
65
+ dsh-better-sidebar or the native preview seat is installed. OFF hands the
66
+ preview back to those external tabs, exactly the pre-0.23 behaviour. */
67
+ shape.inlineDiff = live(schema.boolean().default(true))
68
+ return schema.object(shape)
69
+ }
70
+
71
+ export const Config = Schema === null ? undefined : railConfigSchema(Schema)
@@ -0,0 +1,121 @@
1
+ /**
2
+ * dsh-ide-git host: shared errors, payload validation and the per-repository
3
+ * write queue. No git process runs here; see ./git.js.
4
+ */
5
+ import path from 'node:path'
6
+
7
+ /* ============================== errors ============================== */
8
+
9
+ class PanelError extends Error {
10
+ constructor(code, message, status = 400) {
11
+ super(message)
12
+ this.code = code
13
+ this.status = status
14
+ }
15
+ }
16
+
17
+ function badRequest(message) {
18
+ return new PanelError('bad-request', message, 400)
19
+ }
20
+
21
+ function messageOf(error) {
22
+ return error instanceof Error ? error.message : String(error)
23
+ }
24
+
25
+ /* ============================== payload helpers ============================== */
26
+
27
+ function requireAbsolute(value, label) {
28
+ if (typeof value !== 'string' || value.trim() === '') throw badRequest(label + ' is required')
29
+ if (!path.isAbsolute(value)) throw badRequest(label + ' must be an absolute path')
30
+ return path.resolve(value)
31
+ }
32
+
33
+ /** A ref/branch/tag name: no whitespace, no leading dash, no control characters. */
34
+ function requireRef(value, label) {
35
+ if (typeof value !== 'string' || value.trim() === '') throw badRequest(label + ' is required')
36
+ const name = value.trim()
37
+ if (name.startsWith('-')) throw badRequest(label + ' must not start with "-"')
38
+ if (/[\u0000-\u001f\u007f\s]/.test(name)) throw badRequest(label + ' contains invalid characters')
39
+ return name
40
+ }
41
+
42
+ function optionalRevs(value, label) {
43
+ if (value === undefined || value === null) return []
44
+ if (!Array.isArray(value)) throw badRequest(label + ' must be an array')
45
+ return value.map((entry) => requireRef(entry, label + ' entry'))
46
+ }
47
+
48
+ function requireMessage(value) {
49
+ if (typeof value !== 'string' || value.trim() === '') throw badRequest('message is required')
50
+ return value
51
+ }
52
+
53
+ function cwdOf(payload) {
54
+ return requireAbsolute(payload === undefined ? undefined : payload.cwd, 'cwd')
55
+ }
56
+
57
+ /* ============================== safety ============================== */
58
+
59
+ /**
60
+ * Write operations on one repository run one at a time. The client greys its
61
+ * actions out while a request is in flight, but a stale tab, a second window or
62
+ * a double click could still start two writers, and git dislikes concurrent
63
+ * index writers — so the host serialises writes per repository.
64
+ */
65
+ const repoQueues = new Map()
66
+
67
+ function withRepoLock(key, work) {
68
+ const previous = repoQueues.get(key) === undefined ? Promise.resolve() : repoQueues.get(key)
69
+ const next = previous.then(work, work)
70
+ const settled = next.then(() => undefined, () => undefined)
71
+ repoQueues.set(key, settled)
72
+ settled.then(() => { if (repoQueues.get(key) === settled) repoQueues.delete(key) })
73
+ return next
74
+ }
75
+
76
+ const WRITE_METHODS = new Set([
77
+ 'stage', 'unstage', 'discard', 'commit', 'checkout', 'branchCreate', 'branchRename', 'branch-upstream',
78
+ 'branchDelete', 'worktreeAdd', 'worktreeRemove', 'worktreePrune', 'merge', 'rebase', 'cherryPick', 'revert', 'reset', 'fetch', 'pull',
79
+ 'push', 'stashPush', 'stashApply', 'stashDrop', 'stashClear', 'tagCreate', 'tagDelete', 'tagDeleteRemote', 'pushTags', 'clone', 'remoteAdd', 'remoteRemove', 'branchDeleteRemote', 'undoApply',
80
+ ])
81
+
82
+ function lockKeyOf(payload) {
83
+ if (typeof payload.repoRoot === 'string' && payload.repoRoot !== '') return payload.repoRoot
84
+ if (typeof payload.cwd === 'string' && payload.cwd !== '') return payload.cwd
85
+ return 'global'
86
+ }
87
+
88
+ /* ============================== path helpers ============================== */
89
+
90
+ function requireRelativePath(value) {
91
+ if (typeof value !== 'string' || value.trim() === '') throw badRequest('path is required')
92
+ const trimmed = value.trim()
93
+ if (path.isAbsolute(trimmed)) return trimmed
94
+ if (trimmed.split('/').includes('..')) throw badRequest('path must stay inside the repository')
95
+ return trimmed
96
+ }
97
+
98
+ function optionalPaths(payload, label) {
99
+ if (payload === undefined || payload[label] === undefined || payload[label] === null) return []
100
+ if (!Array.isArray(payload[label])) throw badRequest(label + ' must be an array')
101
+ if (payload[label].length > 5000) throw badRequest(label + ' has too many entries')
102
+ return payload[label].map((entry) => requireRelativePath(entry))
103
+ }
104
+
105
+ function clampInt(value, min, max, fallback) {
106
+ const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10)
107
+ if (!Number.isFinite(parsed)) return fallback
108
+ return Math.min(max, Math.max(min, Math.trunc(parsed)))
109
+ }
110
+
111
+ export {
112
+
113
+ PanelError, badRequest, messageOf,
114
+
115
+ requireAbsolute, requireRef, optionalRevs, requireMessage, cwdOf,
116
+
117
+ requireRelativePath, optionalPaths, clampInt,
118
+
119
+ withRepoLock, WRITE_METHODS, lockKeyOf,
120
+
121
+ }