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