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/host/git.js
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
|
|
3
|
+
* dsh-ide-git host: the argv-only git process and the parsers shared by every
|
|
4
|
+
|
|
5
|
+
* method. Callers never pass a shell string; all input is validated in ./core.js.
|
|
6
|
+
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn } from 'node:child_process'
|
|
10
|
+
|
|
11
|
+
import { realpathSync } from 'node:fs'
|
|
12
|
+
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
|
|
15
|
+
import { PanelError, messageOf, badRequest, requireAbsolute } from './core.js'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024
|
|
20
|
+
|
|
21
|
+
/* Ring buffer of the most recent git runs — the panel's "Git output" view
|
|
22
|
+
(VS Code's scm showOutput equivalent). Bounded on both axes: entries and
|
|
23
|
+
per-entry text, so a hostile `log -p` cannot grow it without limit. */
|
|
24
|
+
const GIT_LOG_MAX_ENTRIES = 200
|
|
25
|
+
const GIT_LOG_TEXT_MAX = 4000
|
|
26
|
+
const gitLog = []
|
|
27
|
+
function pushGitLog(entry) {
|
|
28
|
+
gitLog.push(entry)
|
|
29
|
+
if (gitLog.length > GIT_LOG_MAX_ENTRIES) gitLog.splice(0, gitLog.length - GIT_LOG_MAX_ENTRIES)
|
|
30
|
+
}
|
|
31
|
+
function clipGitLogText(text) {
|
|
32
|
+
if (typeof text !== 'string' || text.length <= GIT_LOG_TEXT_MAX) return text
|
|
33
|
+
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)
|
|
34
|
+
}
|
|
35
|
+
const MAX_DIFF_CHARS = 400 * 1024
|
|
36
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
37
|
+
const NETWORK_TIMEOUT_MS = 180_000
|
|
38
|
+
const RECORD_SEP = '\u001e'
|
|
39
|
+
const FIELD_SEP = '\u001f'
|
|
40
|
+
|
|
41
|
+
/* ============================== argv git ============================== */
|
|
42
|
+
|
|
43
|
+
function gitBinary() {
|
|
44
|
+
const override = process.env.DSH_IDE_GIT_BIN
|
|
45
|
+
return override !== undefined && override.trim() !== '' ? override.trim() : 'git'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Run git with an argv array (no shell). Resolves for ANY exit code so callers
|
|
50
|
+
* can decide whether a non-zero status is data (e.g. `git diff --quiet`) or an
|
|
51
|
+
* error; `git()` below is the throwing form.
|
|
52
|
+
*/
|
|
53
|
+
function runGit(cwd, args, options = {}) {
|
|
54
|
+
const timeoutMs = options.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : options.timeoutMs
|
|
55
|
+
const startedAt = Date.now()
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
let child
|
|
58
|
+
try {
|
|
59
|
+
child = spawn(gitBinary(), args, { cwd, windowsHide: true, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' } })
|
|
60
|
+
} catch (error) {
|
|
61
|
+
reject(new PanelError('git-missing', 'cannot start git: ' + messageOf(error), 500))
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
const out = []
|
|
65
|
+
const err = []
|
|
66
|
+
let outBytes = 0
|
|
67
|
+
let errBytes = 0
|
|
68
|
+
let settled = false
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
if (settled) return
|
|
71
|
+
settled = true
|
|
72
|
+
child.kill('SIGKILL')
|
|
73
|
+
pushGitLog({ at: new Date(startedAt).toISOString(), argv: args.join(' '), code: 'timeout', ms: timeoutMs, out: '', err: 'timed out after ' + String(timeoutMs) + 'ms' })
|
|
74
|
+
reject(new PanelError('git-timeout', 'git ' + args[0] + ' timed out after ' + timeoutMs + 'ms', 504))
|
|
75
|
+
}, timeoutMs)
|
|
76
|
+
child.stdout.on('data', (chunk) => {
|
|
77
|
+
outBytes += chunk.length
|
|
78
|
+
if (outBytes <= MAX_OUTPUT_BYTES) out.push(chunk)
|
|
79
|
+
})
|
|
80
|
+
child.stderr.on('data', (chunk) => {
|
|
81
|
+
errBytes += chunk.length
|
|
82
|
+
if (errBytes <= MAX_OUTPUT_BYTES) err.push(chunk)
|
|
83
|
+
})
|
|
84
|
+
child.on('error', (error) => {
|
|
85
|
+
if (settled) return
|
|
86
|
+
settled = true
|
|
87
|
+
clearTimeout(timer)
|
|
88
|
+
reject(new PanelError('git-missing', 'cannot run git: ' + messageOf(error), 500))
|
|
89
|
+
})
|
|
90
|
+
child.on('close', (code, signal) => {
|
|
91
|
+
if (settled) return
|
|
92
|
+
settled = true
|
|
93
|
+
clearTimeout(timer)
|
|
94
|
+
pushGitLog({
|
|
95
|
+
at: new Date(startedAt).toISOString(),
|
|
96
|
+
argv: args.join(' '),
|
|
97
|
+
code: code === null ? -1 : code,
|
|
98
|
+
ms: Date.now() - startedAt,
|
|
99
|
+
out: clipGitLogText(Buffer.concat(out).toString('utf8')),
|
|
100
|
+
err: clipGitLogText(Buffer.concat(err).toString('utf8')),
|
|
101
|
+
})
|
|
102
|
+
resolve({
|
|
103
|
+
code: code === null ? -1 : code,
|
|
104
|
+
signal: signal === null ? undefined : signal,
|
|
105
|
+
stdout: Buffer.concat(out).toString('utf8'),
|
|
106
|
+
stderr: Buffer.concat(err).toString('utf8'),
|
|
107
|
+
truncated: outBytes > MAX_OUTPUT_BYTES,
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function git(cwd, args, options = {}) {
|
|
114
|
+
const result = await runGit(cwd, args, options)
|
|
115
|
+
if (result.code !== 0) {
|
|
116
|
+
const detail = result.stderr.trim() === '' ? result.stdout.trim() : result.stderr.trim()
|
|
117
|
+
throw new PanelError('git-failed', 'git ' + args.join(' ') + ' failed: ' + (detail === '' ? 'exit ' + result.code : detail), 409)
|
|
118
|
+
}
|
|
119
|
+
return result.stdout
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* Turn the failures users actually hit into named codes (v0.24.0), so the
|
|
123
|
+
panel can answer with an actionable sentence instead of the raw git wall.
|
|
124
|
+
Everything else keeps the honest git-failed message. */
|
|
125
|
+
function gitFailure(command, error) {
|
|
126
|
+
const message = messageOf(error)
|
|
127
|
+
if (command === 'push' && /\[rejected\]|non-fast-forward|fetch first|Updates were rejected because/i.test(message)) {
|
|
128
|
+
return new PanelError('push-rejected', 'push rejected: the remote branch has commits that are not local; pull (merge) first. ' + message, 409)
|
|
129
|
+
}
|
|
130
|
+
if (command === 'pull') {
|
|
131
|
+
if (/CONFLICT|Automatic merge failed|Fix conflicts/i.test(message)) {
|
|
132
|
+
return new PanelError('pull-conflict', 'pull stopped on merge conflicts; resolve them and commit before continuing. ' + message, 409)
|
|
133
|
+
}
|
|
134
|
+
if (/Not possible to fast-forward|Diverging branches/i.test(message)) {
|
|
135
|
+
return new PanelError('pull-diverged', 'local and remote branches have diverged; pull with merge or rebase instead of fast-forward. ' + message, 409)
|
|
136
|
+
}
|
|
137
|
+
if (/Your local changes|would be overwritten by merge|Please commit your changes or stash them/i.test(message)) {
|
|
138
|
+
return new PanelError('pull-dirty', 'pull would overwrite local changes; commit or stash them first. ' + message, 409)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return error
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* ============================== git reads ============================== */
|
|
145
|
+
|
|
146
|
+
async function repoRootOf(cwd, payload) {
|
|
147
|
+
/* Normalize the separator: `rev-parse --show-toplevel` spells drive paths
|
|
148
|
+
with forward slashes on Windows while path.join produces backslashes, so
|
|
149
|
+
the untracked-diff fence (root + path.sep prefix check) rejected every
|
|
150
|
+
in-repo path there. macOS/Linux are byte-identical no-ops. */
|
|
151
|
+
if (payload !== undefined && payload.repoRoot !== undefined) return path.normalize(requireAbsolute(payload.repoRoot, 'repoRoot'))
|
|
152
|
+
const result = await runGit(cwd, ['rev-parse', '--show-toplevel'])
|
|
153
|
+
if (result.code !== 0) {
|
|
154
|
+
// A workspace that is not a repository is a normal state, not a failure:
|
|
155
|
+
// the client answers it with a repo picker instead of an error banner.
|
|
156
|
+
throw new PanelError('not-a-repo', 'not a git repository: ' + cwd, 409)
|
|
157
|
+
}
|
|
158
|
+
const top = result.stdout.trim()
|
|
159
|
+
return path.normalize(top === '' ? cwd : top)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function currentBranchOf(root) {
|
|
163
|
+
const name = (await git(root, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()
|
|
164
|
+
return name
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function upstreamOf(root, branch) {
|
|
168
|
+
const result = await runGit(root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', branch + '@{u}'])
|
|
169
|
+
if (result.code !== 0) return null
|
|
170
|
+
const name = result.stdout.trim()
|
|
171
|
+
return name === '' ? null : name
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function aheadBehindOf(root, upstream, branch) {
|
|
175
|
+
if (upstream === null) return { ahead: 0, behind: 0 }
|
|
176
|
+
const result = await runGit(root, ['rev-list', '--left-right', '--count', upstream + '...' + branch])
|
|
177
|
+
if (result.code !== 0) return { ahead: 0, behind: 0 }
|
|
178
|
+
const parts = result.stdout.trim().split(/\s+/)
|
|
179
|
+
const behind = Number.parseInt(parts[0], 10)
|
|
180
|
+
const ahead = Number.parseInt(parts[1], 10)
|
|
181
|
+
return { ahead: Number.isFinite(ahead) ? ahead : 0, behind: Number.isFinite(behind) ? behind : 0 }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Parse `git status --porcelain=v1 -z` (NUL separated, rename pairs follow). */
|
|
185
|
+
function parsePorcelain(stdout) {
|
|
186
|
+
const records = stdout.split('\u0000')
|
|
187
|
+
const entries = []
|
|
188
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
189
|
+
const record = records[index]
|
|
190
|
+
if (record.length < 4) continue
|
|
191
|
+
const x = record[0]
|
|
192
|
+
const y = record[1]
|
|
193
|
+
const filePath = record.slice(3)
|
|
194
|
+
let origPath
|
|
195
|
+
if (x === 'R' || x === 'C' || y === 'R' || y === 'C') {
|
|
196
|
+
const next = records[index + 1]
|
|
197
|
+
if (next !== undefined && next !== '') {
|
|
198
|
+
origPath = next
|
|
199
|
+
index += 1
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
entries.push({ x, y, path: filePath, origPath })
|
|
203
|
+
}
|
|
204
|
+
return entries
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function classify(entries) {
|
|
208
|
+
const staged = []
|
|
209
|
+
const unstaged = []
|
|
210
|
+
const untracked = []
|
|
211
|
+
const conflicted = []
|
|
212
|
+
for (const entry of entries) {
|
|
213
|
+
const pair = entry.x + entry.y
|
|
214
|
+
if (pair === '??') { untracked.push(entry); continue }
|
|
215
|
+
if (pair === '!!') continue
|
|
216
|
+
if (pair === 'DD' || pair === 'AU' || pair === 'UD' || pair === 'UA' || pair === 'DU' || pair === 'AA' || pair === 'UU') {
|
|
217
|
+
conflicted.push(entry)
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
if (entry.x !== ' ' && entry.x !== '?') staged.push(entry)
|
|
221
|
+
if (entry.y !== ' ' && entry.y !== '?') unstaged.push(entry)
|
|
222
|
+
}
|
|
223
|
+
return { staged, unstaged, untracked, conflicted }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** An ignored tree can be enormous, and this list is informational only. */
|
|
227
|
+
const MAX_IGNORED_ENTRIES = 500
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Ignored paths, asked for on their own. `git status -uall --ignored` expands
|
|
231
|
+
* every file inside an ignored directory — node_modules alone would flood the
|
|
232
|
+
* panel — while `ls-files --directory` reports the directory as a single entry.
|
|
233
|
+
*/
|
|
234
|
+
async function ignoredEntriesOf(root) {
|
|
235
|
+
const result = await runGit(root, ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'])
|
|
236
|
+
if (result.code !== 0) return { entries: [], truncated: false }
|
|
237
|
+
const paths = result.stdout.split('\u0000').filter((entry) => entry !== '')
|
|
238
|
+
const kept = paths.slice(0, MAX_IGNORED_ENTRIES)
|
|
239
|
+
return {
|
|
240
|
+
entries: kept.map((filePath) => ({ index: '!', worktree: '!', path: filePath })),
|
|
241
|
+
truncated: paths.length > kept.length,
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** `git diff --numstat -z` -> Map(path -> { additions, deletions, binary }). */
|
|
246
|
+
async function numstatOf(root, args) {
|
|
247
|
+
const result = await runGit(root, ['diff', '--numstat', '-z', ...args])
|
|
248
|
+
const map = new Map()
|
|
249
|
+
if (result.code !== 0) return map
|
|
250
|
+
const records = result.stdout.split('\u0000')
|
|
251
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
252
|
+
const record = records[index]
|
|
253
|
+
if (record === '') continue
|
|
254
|
+
const firstTab = record.indexOf('\t')
|
|
255
|
+
const secondTab = record.indexOf('\t', firstTab + 1)
|
|
256
|
+
if (firstTab < 0 || secondTab < 0) continue
|
|
257
|
+
const addRaw = record.slice(0, firstTab)
|
|
258
|
+
const delRaw = record.slice(firstTab + 1, secondTab)
|
|
259
|
+
let filePath = record.slice(secondTab + 1)
|
|
260
|
+
if (filePath === '') {
|
|
261
|
+
filePath = records[index + 1] === undefined ? '' : records[index + 1]
|
|
262
|
+
index += 1
|
|
263
|
+
const renamed = records[index + 1]
|
|
264
|
+
if (renamed !== undefined && renamed !== '') index += 1
|
|
265
|
+
}
|
|
266
|
+
const binary = addRaw === '-' || delRaw === '-'
|
|
267
|
+
map.set(filePath, {
|
|
268
|
+
additions: binary ? 0 : Number.parseInt(addRaw, 10) || 0,
|
|
269
|
+
deletions: binary ? 0 : Number.parseInt(delRaw, 10) || 0,
|
|
270
|
+
binary,
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
return map
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function decorate(entries, numstat) {
|
|
277
|
+
return entries.map((entry) => {
|
|
278
|
+
const stat = numstat.get(entry.path)
|
|
279
|
+
return {
|
|
280
|
+
path: entry.path,
|
|
281
|
+
origPath: entry.origPath,
|
|
282
|
+
index: entry.x,
|
|
283
|
+
worktree: entry.y,
|
|
284
|
+
additions: stat === undefined ? 0 : stat.additions,
|
|
285
|
+
deletions: stat === undefined ? 0 : stat.deletions,
|
|
286
|
+
binary: stat === undefined ? false : stat.binary,
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Parse the FIELD_SEP/RECORD_SEP pretty format used by `log`/`show`. */
|
|
292
|
+
function parseLog(stdout) {
|
|
293
|
+
const commits = []
|
|
294
|
+
for (const record of stdout.split(RECORD_SEP)) {
|
|
295
|
+
const trimmed = record.replace(/^\n+/, '')
|
|
296
|
+
if (trimmed.trim() === '') continue
|
|
297
|
+
const parts = trimmed.split(FIELD_SEP)
|
|
298
|
+
if (parts.length < 8) continue
|
|
299
|
+
commits.push({
|
|
300
|
+
hash: parts[0],
|
|
301
|
+
shortHash: parts[1],
|
|
302
|
+
parents: parts[2].trim() === '' ? [] : parts[2].trim().split(' '),
|
|
303
|
+
author: parts[3],
|
|
304
|
+
email: parts[4],
|
|
305
|
+
date: parts[5],
|
|
306
|
+
subject: parts[6],
|
|
307
|
+
refs: parts[7].trim() === '' ? [] : parts[7].split(',').map((ref) => ref.trim()).filter((ref) => ref !== ''),
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
return commits
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
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
|
|
314
|
+
|
|
315
|
+
async function stashCountOf(root) {
|
|
316
|
+
const result = await runGit(root, ['rev-list', '--walk-reflogs', '--count', 'refs/stash'])
|
|
317
|
+
if (result.code !== 0) return 0
|
|
318
|
+
const count = Number.parseInt(result.stdout.trim(), 10)
|
|
319
|
+
return Number.isFinite(count) ? count : 0
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Same directory, tolerating a trailing slash and macOS /var vs /private/var. */
|
|
323
|
+
function sameDirectory(left, right) {
|
|
324
|
+
const one = path.resolve(String(left))
|
|
325
|
+
const two = path.resolve(String(right))
|
|
326
|
+
if (one === two) return true
|
|
327
|
+
try { return realpathSync(one) === realpathSync(two) } catch { return false }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/* `--porcelain -z` is the only parseable form: a path may contain spaces and
|
|
331
|
+
newlines, and git realpaths the main worktree (/var -> /private/var on macOS),
|
|
332
|
+
so every record is NUL-terminated and an empty record closes the current
|
|
333
|
+
block. The flags describe the ADMIN view: `locked` and `prunable` say
|
|
334
|
+
whether removal is blocked or the directory is already gone. */
|
|
335
|
+
async function worktreesOf(root) {
|
|
336
|
+
const result = await runGit(root, ['worktree', 'list', '--porcelain', '-z'])
|
|
337
|
+
if (result.code !== 0) return []
|
|
338
|
+
const trees = []
|
|
339
|
+
let current = null
|
|
340
|
+
const push = () => { if (current !== null) trees.push(current); current = null }
|
|
341
|
+
for (const record of result.stdout.split('\u0000')) {
|
|
342
|
+
if (record === '') { push(); continue }
|
|
343
|
+
if (record.startsWith('worktree ')) {
|
|
344
|
+
push()
|
|
345
|
+
current = {
|
|
346
|
+
path: record.slice('worktree '.length),
|
|
347
|
+
branch: null,
|
|
348
|
+
head: null,
|
|
349
|
+
detached: false,
|
|
350
|
+
bare: false,
|
|
351
|
+
locked: false,
|
|
352
|
+
lockReason: null,
|
|
353
|
+
prunable: false,
|
|
354
|
+
pruneReason: null,
|
|
355
|
+
}
|
|
356
|
+
continue
|
|
357
|
+
}
|
|
358
|
+
if (current === null) continue
|
|
359
|
+
if (record.startsWith('HEAD ')) current.head = record.slice('HEAD '.length)
|
|
360
|
+
else if (record.startsWith('branch ')) current.branch = record.slice('branch '.length).replace('refs/heads/', '')
|
|
361
|
+
else if (record === 'detached') current.detached = true
|
|
362
|
+
else if (record === 'bare') current.bare = true
|
|
363
|
+
else if (record.startsWith('locked')) {
|
|
364
|
+
current.locked = true
|
|
365
|
+
const reason = record.slice('locked'.length).trim()
|
|
366
|
+
current.lockReason = reason === '' ? null : reason
|
|
367
|
+
} else if (record.startsWith('prunable')) {
|
|
368
|
+
current.prunable = true
|
|
369
|
+
const reason = record.slice('prunable'.length).trim()
|
|
370
|
+
current.pruneReason = reason === '' ? null : reason
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
push()
|
|
374
|
+
return trees.map((tree, index) => Object.assign(tree, {
|
|
375
|
+
primary: index === 0,
|
|
376
|
+
current: sameDirectory(tree.path, root),
|
|
377
|
+
}))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/* The repository's own name, cut from its origin URL — a cloned folder that
|
|
381
|
+
was renamed on disk still reports the name the remote gave it (the leaf of
|
|
382
|
+
the URL, minus a trailing .git). scp-style (user@host:path), URL-style and
|
|
383
|
+
plain local paths all cut on the last slash or colon. */
|
|
384
|
+
function repoNameFromUrl(url) {
|
|
385
|
+
if (typeof url !== 'string' || url.trim() === '') return ''
|
|
386
|
+
const tail = url.trim().replace(/\/+$/, '').replace(/\.git$/i, '')
|
|
387
|
+
const segments = tail.split(/[\\/:]/).filter((segment) => segment !== '')
|
|
388
|
+
return segments.length > 0 ? segments[segments.length - 1] : ''
|
|
389
|
+
}
|
|
390
|
+
/* origin first, then the first configured remote, then '' — the client
|
|
391
|
+
falls back to the folder basename when this is empty. Both reads are
|
|
392
|
+
local config reads, never network. */
|
|
393
|
+
async function originNameOf(root) {
|
|
394
|
+
try {
|
|
395
|
+
const origin = await git(root, ['remote', 'get-url', 'origin'])
|
|
396
|
+
const name = repoNameFromUrl(origin)
|
|
397
|
+
if (name !== '') return name
|
|
398
|
+
} catch (error) { void error }
|
|
399
|
+
try {
|
|
400
|
+
const remotesOut = await runGit(root, ['remote'])
|
|
401
|
+
if (remotesOut.code !== 0) return ''
|
|
402
|
+
const first = remotesOut.stdout.split('\n').map((line) => line.trim()).filter((line) => line !== '')[0]
|
|
403
|
+
if (first === undefined) return ''
|
|
404
|
+
const urlOut = await runGit(root, ['remote', 'get-url', first])
|
|
405
|
+
return urlOut.code === 0 ? repoNameFromUrl(urlOut.stdout.trim()) : ''
|
|
406
|
+
} catch (error) { void error; return '' }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export {
|
|
410
|
+
|
|
411
|
+
MAX_DIFF_CHARS, NETWORK_TIMEOUT_MS, FIELD_SEP, RECORD_SEP, gitLog, GIT_LOG_MAX_ENTRIES,
|
|
412
|
+
|
|
413
|
+
gitBinary, runGit, git, gitFailure,
|
|
414
|
+
|
|
415
|
+
repoRootOf, currentBranchOf, upstreamOf, aheadBehindOf,
|
|
416
|
+
|
|
417
|
+
parsePorcelain, classify, ignoredEntriesOf, numstatOf, decorate,
|
|
418
|
+
|
|
419
|
+
parseLog, LOG_FORMAT, stashCountOf, sameDirectory, worktreesOf,
|
|
420
|
+
|
|
421
|
+
repoNameFromUrl, originNameOf,
|
|
422
|
+
|
|
423
|
+
}
|
package/src/host/http.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/** dsh-ide-git host: POST /dsh-ide-git/api/<method> and its trust fence. */
|
|
2
|
+
|
|
3
|
+
import { PanelError, badRequest, messageOf, WRITE_METHODS, withRepoLock, lockKeyOf } from './core.js'
|
|
4
|
+
|
|
5
|
+
import { summary, stats, branches, log, me, mergePoint, commitDetail, diff, compare, gitLogs } from './read.js'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
|
|
9
|
+
stage, unstage, discard, commit, checkout, branchCreate, worktreeAdd, worktreeRemove,
|
|
10
|
+
|
|
11
|
+
worktreePrune, branchUpstream, branchRename, branchDelete, merge, rebase, rebaseAbort,
|
|
12
|
+
|
|
13
|
+
remoteAdd, remoteRemove, branchDeleteRemote, tagDeleteRemote, pushTags, stashClear,
|
|
14
|
+
|
|
15
|
+
cherryPick, revert, reset, fetch, pull, push, stashList, stashPush, stashApply,
|
|
16
|
+
|
|
17
|
+
stashDrop, tagCreate, tagDelete, clone,
|
|
18
|
+
|
|
19
|
+
} from './write.js'
|
|
20
|
+
|
|
21
|
+
import { undoList, undoApply } from './undo.js'
|
|
22
|
+
|
|
23
|
+
import { repos, version } from './repos.js'
|
|
24
|
+
|
|
25
|
+
import { commitMessage, commitModels, commitEfforts } from './commit-message.js'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
const ROUTE_PREFIX = '/dsh-ide-git/api'
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
const METHODS = {
|
|
34
|
+
version,
|
|
35
|
+
'commit-message': commitMessage,
|
|
36
|
+
'commit-models': commitModels,
|
|
37
|
+
'commit-efforts': commitEfforts,
|
|
38
|
+
repos,
|
|
39
|
+
summary,
|
|
40
|
+
stats,
|
|
41
|
+
branches,
|
|
42
|
+
log,
|
|
43
|
+
me,
|
|
44
|
+
'merge-point': mergePoint,
|
|
45
|
+
commitDetail,
|
|
46
|
+
diff,
|
|
47
|
+
compare,
|
|
48
|
+
stage,
|
|
49
|
+
unstage,
|
|
50
|
+
discard,
|
|
51
|
+
commit,
|
|
52
|
+
checkout,
|
|
53
|
+
branchCreate,
|
|
54
|
+
branchRename,
|
|
55
|
+
'branch-upstream': branchUpstream,
|
|
56
|
+
branchDelete,
|
|
57
|
+
worktreeAdd,
|
|
58
|
+
worktreeRemove,
|
|
59
|
+
worktreePrune,
|
|
60
|
+
merge,
|
|
61
|
+
rebase,
|
|
62
|
+
cherryPick,
|
|
63
|
+
revert,
|
|
64
|
+
reset,
|
|
65
|
+
fetch,
|
|
66
|
+
pull,
|
|
67
|
+
push,
|
|
68
|
+
stashList,
|
|
69
|
+
stashPush,
|
|
70
|
+
stashApply,
|
|
71
|
+
stashDrop,
|
|
72
|
+
tagCreate,
|
|
73
|
+
tagDelete,
|
|
74
|
+
tagDeleteRemote,
|
|
75
|
+
pushTags,
|
|
76
|
+
clone,
|
|
77
|
+
gitLogs,
|
|
78
|
+
remoteAdd,
|
|
79
|
+
remoteRemove,
|
|
80
|
+
branchDeleteRemote,
|
|
81
|
+
rebaseAbort,
|
|
82
|
+
stashClear,
|
|
83
|
+
undoList,
|
|
84
|
+
undoApply,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* ============================== http plumbing ============================== */
|
|
88
|
+
|
|
89
|
+
function headerOf(req, name) {
|
|
90
|
+
const value = req.headers[name]
|
|
91
|
+
if (Array.isArray(value)) return value[0]
|
|
92
|
+
return value
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* Normalize an authority ("host[:port]" or an absolute URL) to
|
|
96
|
+
"hostname:port", filling the scheme default port when it is implicit.
|
|
97
|
+
Returns '' for anything unparseable (including the "null" opaque origin). */
|
|
98
|
+
function authorityKey(value, protocol) {
|
|
99
|
+
const text = String(value === undefined || value === null ? '' : value).trim()
|
|
100
|
+
if (text === '') return ''
|
|
101
|
+
let hostname = text
|
|
102
|
+
let port = ''
|
|
103
|
+
let scheme = protocol
|
|
104
|
+
if (text.includes('://') === true) {
|
|
105
|
+
let parsed = null
|
|
106
|
+
try { parsed = new URL(text) } catch (error) { parsed = null }
|
|
107
|
+
if (parsed === null) return ''
|
|
108
|
+
hostname = parsed.hostname
|
|
109
|
+
port = parsed.port
|
|
110
|
+
scheme = parsed.protocol
|
|
111
|
+
} else {
|
|
112
|
+
const bracketed = /^\[([^\]]+)\](?::(\d+))?$/.exec(text)
|
|
113
|
+
if (bracketed !== null) {
|
|
114
|
+
hostname = bracketed[1]
|
|
115
|
+
port = bracketed[2] === undefined ? '' : bracketed[2]
|
|
116
|
+
} else {
|
|
117
|
+
const plain = /^([^:]+)(?::(\d+))?$/.exec(text)
|
|
118
|
+
if (plain === null) return ''
|
|
119
|
+
hostname = plain[1]
|
|
120
|
+
port = plain[2] === undefined ? '' : plain[2]
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (hostname === '') return ''
|
|
124
|
+
if (port === '') port = scheme === 'https:' ? '443' : '80'
|
|
125
|
+
return hostname.toLowerCase() + ':' + port
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* Same-origin fallback for browsers that send no Sec-Fetch-* headers. A page
|
|
129
|
+
served over plain HTTP from a non-loopback address is not a secure context,
|
|
130
|
+
and the fetch-metadata headers are omitted there (issue #20: the git panel
|
|
131
|
+
answered 403 on a LAN address). Origin and Referer are forbidden headers a
|
|
132
|
+
page cannot forge — a cross-site request always carries the caller's own
|
|
133
|
+
origin — so comparing either against the Host header restores the
|
|
134
|
+
same-origin fence without trusting anything the request body claims. */
|
|
135
|
+
function sameOriginOf(req, host) {
|
|
136
|
+
const hostKey = authorityKey(host, 'http:')
|
|
137
|
+
if (hostKey === '') return false
|
|
138
|
+
/* A Host header without a port says nothing about the scheme, so either
|
|
139
|
+
scheme default may be the one the browser used in Origin. */
|
|
140
|
+
const candidates = new Set([hostKey])
|
|
141
|
+
if (/:\d+$/.test(String(host).trim()) !== true) candidates.add(authorityKey(host, 'https:'))
|
|
142
|
+
const matches = (value) => {
|
|
143
|
+
const key = authorityKey(value, 'http:')
|
|
144
|
+
return key !== '' && candidates.has(key)
|
|
145
|
+
}
|
|
146
|
+
if (matches(headerOf(req, 'origin')) === true) return true
|
|
147
|
+
return matches(headerOf(req, 'referer'))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Loopback authority, or a same-origin browser request (desktop shell / remote UI). */
|
|
151
|
+
function isTrusted(req) {
|
|
152
|
+
const host = headerOf(req, 'host')
|
|
153
|
+
if (host === undefined || host === '') return false
|
|
154
|
+
const hostname = host.replace(/^\[/, '').replace(/\]:\d+$/, '').replace(/:\d+$/, '')
|
|
155
|
+
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '0.0.0.0') return true
|
|
156
|
+
if (/^127\./.test(hostname)) return true
|
|
157
|
+
const site = headerOf(req, 'sec-fetch-site')
|
|
158
|
+
if (site === 'same-origin' || site === 'same-site') return true
|
|
159
|
+
return sameOriginOf(req, host)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function sendJson(res, status, body) {
|
|
163
|
+
const text = JSON.stringify(body)
|
|
164
|
+
res.writeHead(status, {
|
|
165
|
+
'content-type': 'application/json; charset=utf-8',
|
|
166
|
+
'cache-control': 'no-store',
|
|
167
|
+
'content-length': Buffer.byteLength(text),
|
|
168
|
+
})
|
|
169
|
+
res.end(text)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function readJsonBody(req, limit = 1 << 20) {
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
const chunks = []
|
|
175
|
+
let size = 0
|
|
176
|
+
req.on('data', (chunk) => {
|
|
177
|
+
size += chunk.length
|
|
178
|
+
if (size > limit) {
|
|
179
|
+
reject(new PanelError('too-large', 'request body is too large', 413))
|
|
180
|
+
req.destroy()
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
chunks.push(chunk)
|
|
184
|
+
})
|
|
185
|
+
req.on('end', () => {
|
|
186
|
+
const raw = Buffer.concat(chunks).toString('utf8')
|
|
187
|
+
if (raw.trim() === '') { resolve({}); return }
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(raw)
|
|
190
|
+
resolve(parsed !== null && typeof parsed === 'object' ? parsed : {})
|
|
191
|
+
} catch {
|
|
192
|
+
reject(badRequest('request body must be JSON'))
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
req.on('error', (error) => { reject(new PanelError('transport', messageOf(error), 500)) })
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function handle(req, res) {
|
|
200
|
+
if (!isTrusted(req)) {
|
|
201
|
+
sendJson(res, 403, {
|
|
202
|
+
ok: false,
|
|
203
|
+
error: {
|
|
204
|
+
code: 'forbidden',
|
|
205
|
+
message: 'forbidden: a non-loopback request must be same-origin (open this host from its own page) or come through the remote pairing proxy',
|
|
206
|
+
},
|
|
207
|
+
})
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
if (req.method !== 'POST') {
|
|
211
|
+
sendJson(res, 405, { ok: false, error: { code: 'method-error', message: 'POST required' } })
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
const pathname = new URL(req.url === undefined ? '/' : req.url, 'http://dsh.internal').pathname
|
|
215
|
+
const method = pathname.startsWith(ROUTE_PREFIX + '/') ? pathname.slice(ROUTE_PREFIX.length + 1) : ''
|
|
216
|
+
const handler = Object.prototype.hasOwnProperty.call(METHODS, method) ? METHODS[method] : undefined
|
|
217
|
+
if (handler === undefined || method.includes('/')) {
|
|
218
|
+
sendJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method "' + method + '"' } })
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const payload = await readJsonBody(req)
|
|
223
|
+
const data = WRITE_METHODS.has(method)
|
|
224
|
+
? await withRepoLock(lockKeyOf(payload), () => handler(payload))
|
|
225
|
+
: await handler(payload)
|
|
226
|
+
sendJson(res, 200, { ok: true, data: data })
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (error instanceof PanelError) {
|
|
229
|
+
sendJson(res, error.status, { ok: false, error: { code: error.code, message: error.message } })
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
const message = messageOf(error)
|
|
233
|
+
sendJson(res, 500, { ok: false, error: { code: 'internal', message } })
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export { ROUTE_PREFIX, handle }
|