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.
- package/README.md +1 -0
- package/README_EN.md +1 -0
- package/dsh.plugin.json +1 -1
- package/package.json +2 -1
- package/src/client.js +358 -13
- package/src/host/commit-message.js +354 -0
- package/src/host/config.js +71 -0
- package/src/host/core.js +121 -0
- package/src/host/git.js +423 -0
- package/src/host/http.js +237 -0
- package/src/host/read.js +348 -0
- package/src/host/repos.js +222 -0
- package/src/host/undo.js +207 -0
- package/src/host/write.js +551 -0
- package/src/index.js +11 -2325
package/src/index.js
CHANGED
|
@@ -1,2334 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-ide-git — host
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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 {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
|
|
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
|
-
|
|
2347
|
-
return () => {
|
|
32
|
+
setCommitServices({ llm: llm, sessions: sessions })
|
|
33
|
+
return () => { setCommitServices(null) }
|
|
2348
34
|
}, 'dsh-ide-git: ai commit message')
|
|
2349
35
|
})
|
|
2350
36
|
}
|