@xiaoming_z/dsh-git 0.0.0-stage → 0.3.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 +998 -2
- package/client.js +3133 -0
- package/cordis.patch.yml +29 -0
- package/docs/composer-branch-chip.png +0 -0
- package/index.js +1320 -0
- package/package.json +44 -4
- package/test/browser/README.md +39 -0
- package/test/browser/drive.mjs +122 -0
- package/test/browser/harness.js +271 -0
- package/test/browser/index.html +41 -0
- package/test/browser/setup.mjs +119 -0
- package/test/client.test.mjs +1258 -0
- package/test/host.test.mjs +817 -0
package/index.js
ADDED
|
@@ -0,0 +1,1320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@xiaoming_z/dsh-git` — Host half of the DSH git panel.
|
|
3
|
+
*
|
|
4
|
+
* `status` / `log` / `diff` / `branches` / `review` for reads, and `stage` /
|
|
5
|
+
* `unstage` / `hunks` / `commit` / `checkout` / `pull` / `push` for writes, all
|
|
6
|
+
* over a plain prefix route on `ctx.webServer`. Going through the webserver
|
|
7
|
+
* route table instead of a Typert Remote is what keeps this package buildable
|
|
8
|
+
* outside the DSH monorepo: Remotes need descriptor codegen at build time, a
|
|
9
|
+
* route needs nothing but the `webServer` service.
|
|
10
|
+
*
|
|
11
|
+
* The route accepts an absolute repository path per request and refuses
|
|
12
|
+
* anything outside `allowedRoots`, because the webserver binds loopback with no
|
|
13
|
+
* authentication of its own. Writes additionally require the `x-dsh-git`
|
|
14
|
+
* header, since this prefix sits outside the `/api` browser-trust fence.
|
|
15
|
+
*
|
|
16
|
+
* @module @xiaoming_z/dsh-git
|
|
17
|
+
*/
|
|
18
|
+
import { execFile } from 'node:child_process'
|
|
19
|
+
import { watch } from 'node:fs'
|
|
20
|
+
import { realpath, stat } from 'node:fs/promises'
|
|
21
|
+
import { join, resolve, sep } from 'node:path'
|
|
22
|
+
|
|
23
|
+
/** Cordis plugin name. */
|
|
24
|
+
export const name = 'dsh-git'
|
|
25
|
+
/** Services this plugin resolves before its body runs. */
|
|
26
|
+
export const inject = ['webServer']
|
|
27
|
+
|
|
28
|
+
/** Prefix the whole route table hangs off. */
|
|
29
|
+
const ROUTE_PREFIX = '/git-api'
|
|
30
|
+
/** Git stdout can be large (a big diff); generous but bounded. */
|
|
31
|
+
const MAX_BUFFER = 32 * 1024 * 1024
|
|
32
|
+
/** Every git child gets a hard deadline so a wedged repo cannot pin the host. */
|
|
33
|
+
const GIT_TIMEOUT_MS = 15_000
|
|
34
|
+
/** Network commands get a longer one: a first fetch of a large repo is slow. */
|
|
35
|
+
const GIT_NETWORK_TIMEOUT_MS = 180_000
|
|
36
|
+
/** Cap on the git output echoed back for a network command. */
|
|
37
|
+
const MAX_NETWORK_OUTPUT = 8 * 1024
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How a pull reconciles a diverged branch.
|
|
41
|
+
*
|
|
42
|
+
* Enumerated rather than passed through: the value reaches git's argv, and an
|
|
43
|
+
* open-ended string would be an option-injection hole. `ff-only` is the default
|
|
44
|
+
* because it is the only one that cannot rewrite history or invent a merge
|
|
45
|
+
* commit behind the user's back.
|
|
46
|
+
*/
|
|
47
|
+
const PULL_STRATEGIES = {
|
|
48
|
+
'ff-only': ['--ff-only'],
|
|
49
|
+
merge: ['--no-rebase'],
|
|
50
|
+
rebase: ['--rebase'],
|
|
51
|
+
}
|
|
52
|
+
/** Coalesce the burst of `.git` writes one git operation produces. */
|
|
53
|
+
const WATCH_DEBOUNCE_MS = 120
|
|
54
|
+
/** Newest-first commit page size when the caller does not ask for one. */
|
|
55
|
+
const DEFAULT_LOG_LIMIT = 200
|
|
56
|
+
/** Hard cap on one log page: the browser renders these, so it must stay finite. */
|
|
57
|
+
const MAX_LOG_LIMIT = 2_000
|
|
58
|
+
|
|
59
|
+
/** Field separator for the log pretty-format. */
|
|
60
|
+
const FS = '\u001f'
|
|
61
|
+
/** Record separator for the log pretty-format. */
|
|
62
|
+
const RS = '\u001e'
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Run one git command in a repository.
|
|
66
|
+
* @param cwd - the repository working tree.
|
|
67
|
+
* @param args - git arguments, already split.
|
|
68
|
+
* @param options - `input` feeds stdin (for `git apply`); `allowExitCodes` lists
|
|
69
|
+
* non-zero exits that mean success for the caller.
|
|
70
|
+
* @returns stdout on success.
|
|
71
|
+
*/
|
|
72
|
+
function git(cwd, args, options = {}) {
|
|
73
|
+
const { input, allowExitCodes = [], timeoutMs = GIT_TIMEOUT_MS } = options
|
|
74
|
+
return new Promise((resolvePromise, reject) => {
|
|
75
|
+
const child = execFile(
|
|
76
|
+
'git',
|
|
77
|
+
// git quotes non-ASCII paths as octal escapes by default
|
|
78
|
+
// (`"a/\345\256\211..."`), which makes every diff header for a CJK
|
|
79
|
+
// filename unreadable. Only output quoting is affected.
|
|
80
|
+
['-c', 'core.quotePath=false', ...args],
|
|
81
|
+
{
|
|
82
|
+
cwd,
|
|
83
|
+
maxBuffer: MAX_BUFFER,
|
|
84
|
+
timeout: timeoutMs,
|
|
85
|
+
windowsHide: true,
|
|
86
|
+
// Without a TTY a credential prompt cannot be answered, and git would
|
|
87
|
+
// sit there until the timeout kills it. Refusing immediately turns a
|
|
88
|
+
// hang into a message the panel can show.
|
|
89
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
90
|
+
},
|
|
91
|
+
(error, stdout, stderr) => {
|
|
92
|
+
if (error !== null) {
|
|
93
|
+
// Some commands signal a normal outcome with a non-zero exit:
|
|
94
|
+
// `diff --no-index` exits 1 when the inputs differ, which is the
|
|
95
|
+
// expected case for an untracked file.
|
|
96
|
+
if (typeof error.code === 'number' && allowExitCodes.includes(error.code)) {
|
|
97
|
+
resolvePromise(stdout)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
// git does not agree with itself on which stream carries a
|
|
101
|
+
// diagnostic: `merge` conflicts land on stdout, `commit` with an
|
|
102
|
+
// empty index prints "nothing to commit" on stdout, while a bad
|
|
103
|
+
// revision goes to stderr. Check both before falling back to the
|
|
104
|
+
// useless "Command failed" text.
|
|
105
|
+
const detail = String(stderr ?? '').trim() || String(stdout ?? '').trim() || error.message
|
|
106
|
+
reject(new Error(detail))
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
resolvePromise(stdout)
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
if (input !== undefined) {
|
|
113
|
+
// If git exits before reading (a bad patch, an unknown option), the write
|
|
114
|
+
// fails with EPIPE; the exit callback owns the diagnosis.
|
|
115
|
+
child.stdin.on('error', () => {})
|
|
116
|
+
child.stdin.end(input)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Cap on a synthesised diff for an untracked file. */
|
|
122
|
+
const MAX_UNTRACKED_DIFF_BYTES = 400 * 1024
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Split a unified diff into its file header and its hunks.
|
|
126
|
+
*
|
|
127
|
+
* `git apply` needs a patch assembled from a subset of hunks with the file
|
|
128
|
+
* header intact — exactly the split `git add -p` performs internally. Skipping
|
|
129
|
+
* an earlier hunk does not invalidate a later one's line numbers, because the
|
|
130
|
+
* index those numbers refer to is left untouched by the skipped hunk.
|
|
131
|
+
* @param text - a unified diff.
|
|
132
|
+
* @returns the header lines and one string per `@@` hunk.
|
|
133
|
+
*/
|
|
134
|
+
export function splitHunks(text) {
|
|
135
|
+
const header = []
|
|
136
|
+
const hunks = []
|
|
137
|
+
let current = null
|
|
138
|
+
for (const line of text.split('\n')) {
|
|
139
|
+
if (line.startsWith('@@')) {
|
|
140
|
+
if (current !== null) hunks.push(current.join('\n'))
|
|
141
|
+
current = [line]
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
if (current === null) header.push(line)
|
|
145
|
+
else current.push(line)
|
|
146
|
+
}
|
|
147
|
+
if (current !== null) hunks.push(current.join('\n'))
|
|
148
|
+
// The final `split('\n')` element is an empty string when the diff ends with
|
|
149
|
+
// a newline; drop trailing newlines so reassembly adds exactly one.
|
|
150
|
+
return { header, hunks: hunks.map((hunk) => hunk.replace(/\n+$/, '')) }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Parse `git diff --numstat -z`.
|
|
155
|
+
*
|
|
156
|
+
* Records are `added\tdeleted\tpath` terminated by NUL. A rename emits an empty
|
|
157
|
+
* path field and then the two paths as their own NUL-separated fields; a binary
|
|
158
|
+
* file reports `-` for both counts. Fields are split on the FIRST TWO tabs
|
|
159
|
+
* rather than `split('\t')` because a path may itself contain a tab.
|
|
160
|
+
* @param raw - the command's raw stdout.
|
|
161
|
+
* @returns one entry per changed path.
|
|
162
|
+
*/
|
|
163
|
+
export function parseNumstat(raw) {
|
|
164
|
+
const files = []
|
|
165
|
+
const fields = raw.split('\0')
|
|
166
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
167
|
+
const record = fields[i]
|
|
168
|
+
if (record === '') continue
|
|
169
|
+
const first = record.indexOf('\t')
|
|
170
|
+
const second = first === -1 ? -1 : record.indexOf('\t', first + 1)
|
|
171
|
+
if (second === -1) continue
|
|
172
|
+
const added = record.slice(0, first)
|
|
173
|
+
const deleted = record.slice(first + 1, second)
|
|
174
|
+
let path = record.slice(second + 1)
|
|
175
|
+
let origPath = null
|
|
176
|
+
if (path === '') {
|
|
177
|
+
origPath = fields[i + 1] ?? null
|
|
178
|
+
path = fields[i + 2] ?? ''
|
|
179
|
+
i += 2
|
|
180
|
+
}
|
|
181
|
+
if (path === '') continue
|
|
182
|
+
files.push({
|
|
183
|
+
path,
|
|
184
|
+
origPath,
|
|
185
|
+
added: added === '-' ? null : Number(added),
|
|
186
|
+
deleted: deleted === '-' ? null : Number(deleted),
|
|
187
|
+
binary: added === '-',
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
return files
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Parse `git diff --name-status -z` into a map keyed by the resulting path.
|
|
195
|
+
*
|
|
196
|
+
* Status and paths are all NUL-separated here (not tab), and a rename/copy
|
|
197
|
+
* carries two paths, so the record length depends on the status letter.
|
|
198
|
+
* @param raw - the command's raw stdout.
|
|
199
|
+
* @returns status by resulting path.
|
|
200
|
+
*/
|
|
201
|
+
export function parseNameStatus(raw) {
|
|
202
|
+
const byPath = new Map()
|
|
203
|
+
const fields = raw.split('\0')
|
|
204
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
205
|
+
const status = fields[i]
|
|
206
|
+
if (status === '') continue
|
|
207
|
+
if (status[0] === 'R' || status[0] === 'C') {
|
|
208
|
+
const from = fields[i + 1] ?? ''
|
|
209
|
+
const to = fields[i + 2] ?? ''
|
|
210
|
+
if (to !== '') byPath.set(to, { status: status[0], score: status.slice(1), origPath: from })
|
|
211
|
+
i += 2
|
|
212
|
+
} else {
|
|
213
|
+
const path = fields[i + 1] ?? ''
|
|
214
|
+
if (path !== '') byPath.set(path, { status: status[0], score: null, origPath: null })
|
|
215
|
+
i += 1
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return byPath
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Parse `git status --porcelain=v2 --branch -z`.
|
|
223
|
+
*
|
|
224
|
+
* Record shapes (the path field is last and may contain spaces, so it is
|
|
225
|
+
* re-joined rather than indexed):
|
|
226
|
+
* `1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>` ordinary change
|
|
227
|
+
* `2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>` rename/copy
|
|
228
|
+
* `u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>` unmerged
|
|
229
|
+
* `? <path>` untracked
|
|
230
|
+
*
|
|
231
|
+
* With `-z`, records are NUL-separated and a type-`2` record is followed by one
|
|
232
|
+
* extra NUL-separated field holding the original path.
|
|
233
|
+
*
|
|
234
|
+
* @param raw - the command's raw stdout.
|
|
235
|
+
* @returns the branch header plus files grouped by index/worktree position.
|
|
236
|
+
*/
|
|
237
|
+
export function parseStatusV2(raw) {
|
|
238
|
+
const branch = {
|
|
239
|
+
head: null,
|
|
240
|
+
oid: null,
|
|
241
|
+
upstream: null,
|
|
242
|
+
ahead: 0,
|
|
243
|
+
behind: 0,
|
|
244
|
+
detached: false,
|
|
245
|
+
initial: false,
|
|
246
|
+
}
|
|
247
|
+
const staged = []
|
|
248
|
+
const unstaged = []
|
|
249
|
+
const untracked = []
|
|
250
|
+
const conflicted = []
|
|
251
|
+
const fields = raw.split('\0')
|
|
252
|
+
|
|
253
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
254
|
+
const line = fields[i]
|
|
255
|
+
if (line === '') continue
|
|
256
|
+
|
|
257
|
+
if (line.startsWith('# ')) {
|
|
258
|
+
const [key, ...rest] = line.slice(2).split(' ')
|
|
259
|
+
const value = rest.join(' ')
|
|
260
|
+
if (key === 'branch.oid') {
|
|
261
|
+
branch.oid = value === '(initial)' ? null : value
|
|
262
|
+
branch.initial = value === '(initial)'
|
|
263
|
+
} else if (key === 'branch.head') {
|
|
264
|
+
branch.detached = value === '(detached)'
|
|
265
|
+
branch.head = branch.detached ? null : value
|
|
266
|
+
} else if (key === 'branch.upstream') {
|
|
267
|
+
branch.upstream = value
|
|
268
|
+
} else if (key === 'branch.ab') {
|
|
269
|
+
const [, ahead, behind] = /^\+(\d+) -(\d+)$/.exec(value) ?? []
|
|
270
|
+
branch.ahead = Number(ahead ?? 0)
|
|
271
|
+
branch.behind = Number(behind ?? 0)
|
|
272
|
+
}
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const kind = line[0]
|
|
277
|
+
if (kind === '?') {
|
|
278
|
+
// Untracked entries carry the same field set as every other kind. An
|
|
279
|
+
// absent `origPath` used to reach the browser as `undefined`, where a
|
|
280
|
+
// `!== null` guard happily rendered the literal text "undefined".
|
|
281
|
+
untracked.push({ path: line.slice(2), index: '?', worktree: '?', origPath: null })
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
if (kind !== '1' && kind !== '2' && kind !== 'u') continue
|
|
285
|
+
|
|
286
|
+
const parts = line.split(' ')
|
|
287
|
+
// Where the path starts depends on the record kind (see the shapes above).
|
|
288
|
+
const pathStart = kind === '1' ? 8 : kind === '2' ? 9 : 10
|
|
289
|
+
const xy = parts[1] ?? '..'
|
|
290
|
+
const x = xy[0]
|
|
291
|
+
const y = xy[1]
|
|
292
|
+
const entry = {
|
|
293
|
+
path: parts.slice(pathStart).join(' '),
|
|
294
|
+
index: x,
|
|
295
|
+
worktree: y,
|
|
296
|
+
origPath: null,
|
|
297
|
+
}
|
|
298
|
+
if (kind === '2') {
|
|
299
|
+
entry.origPath = fields[i + 1] ?? null
|
|
300
|
+
i += 1
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (kind === 'u') {
|
|
304
|
+
conflicted.push(entry)
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
if (x !== '.' && x !== ' ') staged.push(entry)
|
|
308
|
+
if (y !== '.' && y !== ' ') unstaged.push(entry)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { branch, staged, unstaged, untracked, conflicted }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Parse the `%x1f`/`%x1e`-delimited log produced by {@link GitService.log}.
|
|
316
|
+
* @param raw - the command's raw stdout.
|
|
317
|
+
* @returns commits newest-first.
|
|
318
|
+
*/
|
|
319
|
+
export function parseLog(raw) {
|
|
320
|
+
const commits = []
|
|
321
|
+
for (const chunk of raw.split(RS)) {
|
|
322
|
+
// `format:` separates entries with a newline, so every record but the first
|
|
323
|
+
// arrives with a leading one. Only leading newlines are stripped: the
|
|
324
|
+
// subject is the last field and may legitimately end in spaces.
|
|
325
|
+
const record = chunk.replace(/^[\r\n]+/, '')
|
|
326
|
+
if (record === '') continue
|
|
327
|
+
const [hash, parents, authorName, authorEmail, authoredAt, ...subject] = record.split(FS)
|
|
328
|
+
commits.push({
|
|
329
|
+
hash,
|
|
330
|
+
parents: parents === '' ? [] : parents.split(' ').filter(Boolean),
|
|
331
|
+
author: authorName,
|
|
332
|
+
email: authorEmail,
|
|
333
|
+
// Seconds since epoch is what git prints; the browser wants milliseconds.
|
|
334
|
+
at: Number(authoredAt) * 1000,
|
|
335
|
+
// The subject is last so an embedded separator cannot shift the fields.
|
|
336
|
+
subject: subject.join(FS),
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
return commits
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Answer one request as JSON.
|
|
344
|
+
* @param res - the node response.
|
|
345
|
+
* @param status - HTTP status code.
|
|
346
|
+
* @param payload - JSON-serializable body.
|
|
347
|
+
*/
|
|
348
|
+
function send(res, status, payload) {
|
|
349
|
+
const body = JSON.stringify(payload)
|
|
350
|
+
res.writeHead(status, {
|
|
351
|
+
'content-type': 'application/json; charset=utf-8',
|
|
352
|
+
'cache-control': 'no-store',
|
|
353
|
+
'content-length': Buffer.byteLength(body),
|
|
354
|
+
})
|
|
355
|
+
res.end(body)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Header a write request must carry; see {@link assertNotCrossSite}. */
|
|
359
|
+
const WRITE_HEADER = 'x-dsh-git'
|
|
360
|
+
|
|
361
|
+
/** Cap on a request body. A commit message is the only body this API accepts. */
|
|
362
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Read and parse a JSON request body.
|
|
366
|
+
* @param req - the node request.
|
|
367
|
+
* @returns the parsed object, or `{}` for an empty body.
|
|
368
|
+
*/
|
|
369
|
+
function readJsonBody(req) {
|
|
370
|
+
return new Promise((resolve, reject) => {
|
|
371
|
+
const chunks = []
|
|
372
|
+
let size = 0
|
|
373
|
+
req.on('data', (chunk) => {
|
|
374
|
+
size += chunk.length
|
|
375
|
+
if (size > MAX_BODY_BYTES) {
|
|
376
|
+
reject(new Error(`request body exceeds ${MAX_BODY_BYTES} bytes`))
|
|
377
|
+
req.destroy()
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
chunks.push(chunk)
|
|
381
|
+
})
|
|
382
|
+
req.on('end', () => {
|
|
383
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim()
|
|
384
|
+
if (raw === '') {
|
|
385
|
+
resolve({})
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
const parsed = JSON.parse(raw)
|
|
390
|
+
resolve(parsed !== null && typeof parsed === 'object' ? parsed : {})
|
|
391
|
+
} catch {
|
|
392
|
+
reject(new Error('request body is not valid JSON'))
|
|
393
|
+
}
|
|
394
|
+
})
|
|
395
|
+
req.on('error', reject)
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Validate a revision the browser wants git to act on.
|
|
401
|
+
*
|
|
402
|
+
* A ref goes straight into argv, so a leading `-` would be read as an option —
|
|
403
|
+
* `git checkout -f` instead of checking out a branch named `-f`. Reads tolerate
|
|
404
|
+
* a bad ref by falling back to HEAD; writes must refuse loudly instead.
|
|
405
|
+
* @param value - the caller's ref.
|
|
406
|
+
* @param label - field name for the error message.
|
|
407
|
+
* @returns the ref.
|
|
408
|
+
*/
|
|
409
|
+
function requireRef(value, label) {
|
|
410
|
+
if (typeof value !== 'string' || value.trim() === '') throw new Error(`${label} must not be empty`)
|
|
411
|
+
if (value.startsWith('-')) throw new Error(`${label} must not start with "-": ${JSON.stringify(value)}`)
|
|
412
|
+
return value
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Resolve the caller's declared action list.
|
|
417
|
+
* @param value - the request's `files` field.
|
|
418
|
+
* @returns a non-empty array of pathspecs.
|
|
419
|
+
*/
|
|
420
|
+
function requireFiles(value) {
|
|
421
|
+
if (!Array.isArray(value) || value.length === 0) throw new Error('files must be a non-empty array')
|
|
422
|
+
for (const file of value) {
|
|
423
|
+
if (typeof file !== 'string' || file === '') throw new Error('every entry in files must be a non-empty string')
|
|
424
|
+
}
|
|
425
|
+
return value
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Reject a write that did not come from the panel.
|
|
430
|
+
*
|
|
431
|
+
* The webserver binds loopback and adds no authentication of its own, and this
|
|
432
|
+
* route table is deliberately *outside* the `/api` browser-trust fence — which
|
|
433
|
+
* is what lets a plugin own it. That combination means a write endpoint is
|
|
434
|
+
* reachable by any page the user has open. Requiring a custom header is the
|
|
435
|
+
* defence: a cross-origin form or `fetch` cannot set one without triggering a
|
|
436
|
+
* CORS preflight, and this server answers no preflight and sends no CORS
|
|
437
|
+
* headers, so the browser blocks the request before it arrives.
|
|
438
|
+
* @param req - the node request.
|
|
439
|
+
*/
|
|
440
|
+
function assertNotCrossSite(req) {
|
|
441
|
+
if (req.headers[WRITE_HEADER] === undefined) {
|
|
442
|
+
throw new Error(`refusing a write without the ${WRITE_HEADER} header (cross-site protection)`)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Parse `git show --name-status` into a commit header plus its changed files.
|
|
448
|
+
* @param raw - the command's raw stdout.
|
|
449
|
+
* @returns the commit and its files, or null when nothing parsed.
|
|
450
|
+
*/
|
|
451
|
+
export function parseCommitFiles(raw) {
|
|
452
|
+
const [header, ...lines] = raw.split('\n')
|
|
453
|
+
const [hash, author, authoredAt, ...subject] = header.split(FS)
|
|
454
|
+
if (hash === undefined || hash === '') return null
|
|
455
|
+
const files = []
|
|
456
|
+
for (const line of lines) {
|
|
457
|
+
if (line.trim() === '') continue
|
|
458
|
+
const [status, ...rest] = line.split('\t')
|
|
459
|
+
if (status === undefined || rest.length === 0) continue
|
|
460
|
+
// A rename/copy line carries both paths; the others carry one. Only R/C
|
|
461
|
+
// carry a trailing similarity score — a merge's combined status ("MM")
|
|
462
|
+
// would otherwise be misread as one.
|
|
463
|
+
const scored = status[0] === 'R' || status[0] === 'C'
|
|
464
|
+
files.push({
|
|
465
|
+
status: status[0],
|
|
466
|
+
score: scored && status.length > 1 ? status.slice(1) : null,
|
|
467
|
+
path: rest[rest.length - 1],
|
|
468
|
+
origPath: rest.length > 1 ? rest[0] : null,
|
|
469
|
+
})
|
|
470
|
+
}
|
|
471
|
+
return {
|
|
472
|
+
commit: { hash, author, at: Number(authoredAt) * 1000, subject: subject.join(FS) },
|
|
473
|
+
files,
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Per-repository git state: a small read cache plus the `.git` watcher that
|
|
479
|
+
* invalidates it.
|
|
480
|
+
*
|
|
481
|
+
* This is the seam that makes the panel feel like an IDE rather than a form
|
|
482
|
+
* submit: the browser never waits on a cold `git status`, and any git operation
|
|
483
|
+
* taken outside the panel (a shell commit, an IDE checkout) drops the cache
|
|
484
|
+
* instead of leaving a stale tree on screen.
|
|
485
|
+
*/
|
|
486
|
+
class GitService {
|
|
487
|
+
#allowedRoots
|
|
488
|
+
#allowAny
|
|
489
|
+
#defaultRoot
|
|
490
|
+
#canonicalRoots = null
|
|
491
|
+
#cache = new Map()
|
|
492
|
+
#watchers = new Map()
|
|
493
|
+
#timers = new Map()
|
|
494
|
+
|
|
495
|
+
#knownWorkspaces
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* @param config - the row's config from `cordis.patch.yml`.
|
|
499
|
+
* @param knownWorkspaces - returns directories the harness itself considers
|
|
500
|
+
* workspaces; they are trusted in addition to `allowedRoots`.
|
|
501
|
+
*/
|
|
502
|
+
constructor(config = {}, knownWorkspaces = () => []) {
|
|
503
|
+
const roots = Array.isArray(config.allowedRoots) ? config.allowedRoots : [process.cwd()]
|
|
504
|
+
this.#allowAny = roots.includes('*')
|
|
505
|
+
this.#allowedRoots = roots.filter((root) => root !== '*').map((root) => resolve(root))
|
|
506
|
+
this.#defaultRoot = typeof config.defaultRoot === 'string' ? resolve(config.defaultRoot) : process.cwd()
|
|
507
|
+
this.#knownWorkspaces = knownWorkspaces
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Canonicalize the allowlist once, lazily.
|
|
512
|
+
*
|
|
513
|
+
* The request path is canonicalized with `realpath` before the prefix check,
|
|
514
|
+
* so the allowlist must be canonicalized the same way or the two never
|
|
515
|
+
* compare equal: on macOS `/tmp` and `/var` are symlinks into `/private`, and
|
|
516
|
+
* a project reached through a symlinked directory has the same problem.
|
|
517
|
+
* Canonicalizing lazily (rather than in the constructor) keeps `apply()`
|
|
518
|
+
* synchronous and tolerates a configured root that does not exist yet.
|
|
519
|
+
* @returns the canonical allowlist.
|
|
520
|
+
*/
|
|
521
|
+
async #canonicalAllowlist() {
|
|
522
|
+
if (this.#canonicalRoots === null) {
|
|
523
|
+
this.#canonicalRoots = (async () => {
|
|
524
|
+
const roots = []
|
|
525
|
+
for (const root of this.#allowedRoots) {
|
|
526
|
+
// A root that cannot be canonicalized is kept as-is, so a typo in the
|
|
527
|
+
// config matches nothing rather than throwing at every request.
|
|
528
|
+
roots.push(await realpath(root).catch(() => root))
|
|
529
|
+
}
|
|
530
|
+
return roots
|
|
531
|
+
})()
|
|
532
|
+
}
|
|
533
|
+
return await this.#canonicalRoots
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Canonicalize the registered workspace roots.
|
|
538
|
+
*
|
|
539
|
+
* Not memoized: a workspace can be added or removed while the panel is open,
|
|
540
|
+
* and the list is short enough that re-canonicalizing per request is cheap.
|
|
541
|
+
* @returns the canonical workspace paths.
|
|
542
|
+
*/
|
|
543
|
+
async #canonicalWorkspaces() {
|
|
544
|
+
let paths
|
|
545
|
+
try {
|
|
546
|
+
paths = this.#knownWorkspaces()
|
|
547
|
+
} catch {
|
|
548
|
+
return []
|
|
549
|
+
}
|
|
550
|
+
if (!Array.isArray(paths)) return []
|
|
551
|
+
const roots = []
|
|
552
|
+
for (const path of paths) {
|
|
553
|
+
if (typeof path !== 'string' || path === '') continue
|
|
554
|
+
roots.push(await realpath(path).catch(() => null))
|
|
555
|
+
}
|
|
556
|
+
return roots.filter((root) => root !== null)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Canonicalize and vet a caller-supplied repository path.
|
|
561
|
+
* @param input - the `root` query parameter, or undefined to use the default.
|
|
562
|
+
* @returns the canonical repository root.
|
|
563
|
+
*/
|
|
564
|
+
async resolveRoot(input) {
|
|
565
|
+
const candidate = typeof input === 'string' && input.trim() !== '' ? resolve(input) : this.#defaultRoot
|
|
566
|
+
const real = await realpath(candidate)
|
|
567
|
+
if (!(await stat(real)).isDirectory()) throw new Error(`${real} is not a directory`)
|
|
568
|
+
if (!this.#allowAny) {
|
|
569
|
+
const allowed = await this.#canonicalAllowlist()
|
|
570
|
+
// Directories the harness already treats as workspaces are trusted too.
|
|
571
|
+
// That is what makes the panel follow the session's repository without
|
|
572
|
+
// widening `allowedRoots` to every path on the machine: the user opted
|
|
573
|
+
// into these by opening them.
|
|
574
|
+
const workspaces = await this.#canonicalWorkspaces()
|
|
575
|
+
const ok = (root) => real === root || real.startsWith(root + sep)
|
|
576
|
+
if (!allowed.some(ok) && !workspaces.some(ok)) {
|
|
577
|
+
throw new Error(
|
|
578
|
+
`refusing to inspect ${real}: outside allowedRoots and not a known workspace ` +
|
|
579
|
+
'(add it to the row config, or use "*")',
|
|
580
|
+
)
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
await stat(join(real, '.git'))
|
|
585
|
+
} catch {
|
|
586
|
+
throw new Error(`${real} is not a git working tree`)
|
|
587
|
+
}
|
|
588
|
+
// The watcher is set up in the background: a repository that cannot be
|
|
589
|
+
// watched still works, it just refreshes on demand instead of on change.
|
|
590
|
+
void this.#watch(real)
|
|
591
|
+
return real
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* The working tree's status, cached until the watcher invalidates it.
|
|
596
|
+
* @param root - canonical repository root.
|
|
597
|
+
* @returns parsed porcelain-v2 status.
|
|
598
|
+
*/
|
|
599
|
+
async status(root) {
|
|
600
|
+
return await this.#cached(`${root}\u0000status`, async () =>
|
|
601
|
+
parseStatusV2(await git(root, ['status', '--porcelain=v2', '--branch', '--untracked-files=all', '-z'])),
|
|
602
|
+
)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* A page of commits, newest-first, in topological order so the client's lane
|
|
607
|
+
* assignment does not have to cope with a parent arriving after its child.
|
|
608
|
+
* @param root - canonical repository root.
|
|
609
|
+
* @param limit - requested page size, clamped to [1, MAX_LOG_LIMIT].
|
|
610
|
+
* @param ref - optional branch/ref to walk instead of HEAD.
|
|
611
|
+
* @returns parsed commits.
|
|
612
|
+
*/
|
|
613
|
+
async log(root, limit, ref) {
|
|
614
|
+
const size = Math.min(Math.max(1, Number(limit) || DEFAULT_LOG_LIMIT), MAX_LOG_LIMIT)
|
|
615
|
+
// A ref is passed as a revision argument, so a leading `-` would be read as
|
|
616
|
+
// an option. Reject it rather than letting a caller reach git's flag space.
|
|
617
|
+
const revision = typeof ref === 'string' && ref !== '' && !ref.startsWith('-') ? ref : undefined
|
|
618
|
+
return await this.#cached(`${root}\u0000log\u0000${size}\u0000${revision ?? ''}`, async () =>
|
|
619
|
+
parseLog(
|
|
620
|
+
await git(root, [
|
|
621
|
+
'log',
|
|
622
|
+
`--max-count=${size}`,
|
|
623
|
+
'--topo-order',
|
|
624
|
+
`--pretty=format:%H${FS}%P${FS}%an${FS}%ae${FS}%at${FS}%s${RS}`,
|
|
625
|
+
...(revision === undefined ? [] : [revision]),
|
|
626
|
+
]),
|
|
627
|
+
),
|
|
628
|
+
)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* A unified diff. Deliberately uncached: it is the pane the user is actively
|
|
633
|
+
* editing against, and a stale diff is worse than a second git call.
|
|
634
|
+
* @param root - canonical repository root.
|
|
635
|
+
* @param file - optional pathspec.
|
|
636
|
+
* @param staged - diff the index against HEAD instead of the worktree.
|
|
637
|
+
* @param untracked - synthesise an all-added diff for an untracked file.
|
|
638
|
+
* @param since - diff one path against this marker instead of the index.
|
|
639
|
+
* @returns raw unified diff text.
|
|
640
|
+
*/
|
|
641
|
+
async diff(root, file, staged, untracked, since) {
|
|
642
|
+
if (since !== undefined && since !== null && since !== '') {
|
|
643
|
+
const marker = requireRef(since, 'since')
|
|
644
|
+
const args = ['diff', '--no-color', '--no-ext-diff', marker]
|
|
645
|
+
if (typeof file === 'string' && file !== '') args.push('--', file)
|
|
646
|
+
return await git(root, args)
|
|
647
|
+
}
|
|
648
|
+
if (untracked === true) return await this.#untrackedDiff(root, file)
|
|
649
|
+
const args = ['diff', '--no-color', '--no-ext-diff']
|
|
650
|
+
if (staged === true) args.push('--cached')
|
|
651
|
+
if (typeof file === 'string' && file !== '') args.push('--', file)
|
|
652
|
+
return await git(root, args)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* An untracked file has no diff against the index, so one is synthesised by
|
|
657
|
+
* diffing it against the null device. `--no-index` exits 1 whenever the
|
|
658
|
+
* inputs differ, which is the normal case here and is accepted.
|
|
659
|
+
* @param root - canonical repository root.
|
|
660
|
+
* @param file - path relative to the repository root.
|
|
661
|
+
* @returns the synthesised diff, truncated when the file is very large.
|
|
662
|
+
*/
|
|
663
|
+
async #untrackedDiff(root, file) {
|
|
664
|
+
if (typeof file !== 'string' || file === '') throw new Error('untracked diff needs a file path')
|
|
665
|
+
// The path arrives from the browser, so it is confined to the repository
|
|
666
|
+
// before it reaches git at all.
|
|
667
|
+
const absolute = resolve(root, file)
|
|
668
|
+
if (absolute !== root && !absolute.startsWith(root + sep)) {
|
|
669
|
+
throw new Error(`${file} is outside the repository`)
|
|
670
|
+
}
|
|
671
|
+
const info = await stat(absolute).catch(() => null)
|
|
672
|
+
if (info === null || !info.isFile()) throw new Error(`${file} does not exist or is not a regular file`)
|
|
673
|
+
const nullDevice = process.platform === 'win32' ? 'NUL' : '/dev/null'
|
|
674
|
+
const text = await git(root, ['diff', '--no-color', '--no-ext-diff', '--no-index', '--', nullDevice, file], {
|
|
675
|
+
allowExitCodes: [1],
|
|
676
|
+
})
|
|
677
|
+
if (Buffer.byteLength(text) <= MAX_UNTRACKED_DIFF_BYTES) return text
|
|
678
|
+
return `${text.slice(0, MAX_UNTRACKED_DIFF_BYTES)}\n… diff truncated: the file is too large to show in full\n`
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Stage (or unstage) a chosen subset of a file's hunks.
|
|
683
|
+
*
|
|
684
|
+
* The patch is rebuilt from the file header plus the selected hunks and piped
|
|
685
|
+
* to `git apply --cached`, which is how `git add -p` / `git reset -p` work.
|
|
686
|
+
* @param root - canonical repository root.
|
|
687
|
+
* @param file - path relative to the repository root.
|
|
688
|
+
* @param indices - zero-based hunk indices, in diff order.
|
|
689
|
+
* @param staged - true unstages the selected hunks from the index.
|
|
690
|
+
*/
|
|
691
|
+
async applyHunks(root, file, indices, staged) {
|
|
692
|
+
const text = await this.diff(root, file, staged === true, false)
|
|
693
|
+
const { header, hunks } = splitHunks(text)
|
|
694
|
+
if (hunks.length === 0) throw new Error('this file has no hunks to apply')
|
|
695
|
+
const picked = []
|
|
696
|
+
for (const index of Array.isArray(indices) ? indices : []) {
|
|
697
|
+
if (!Number.isInteger(index) || index < 0 || index >= hunks.length) {
|
|
698
|
+
throw new Error(`hunk index out of range: ${JSON.stringify(index)} (this file has ${hunks.length})`)
|
|
699
|
+
}
|
|
700
|
+
picked.push(hunks[index])
|
|
701
|
+
}
|
|
702
|
+
if (picked.length === 0) throw new Error('no hunks selected')
|
|
703
|
+
const patch = `${[...header, ...picked].join('\n')}\n`
|
|
704
|
+
const args = ['apply', '--cached', '--whitespace=nowarn', '--recount']
|
|
705
|
+
if (staged === true) args.push('--reverse')
|
|
706
|
+
args.push('-')
|
|
707
|
+
await git(root, args, { input: patch })
|
|
708
|
+
this.invalidate(root)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Local and remote branches, split for the panel's branch tree.
|
|
713
|
+
* @param root - canonical repository root.
|
|
714
|
+
* @returns `{local, remote}`; a remote entry carries its remote and branch name.
|
|
715
|
+
*/
|
|
716
|
+
async branches(root) {
|
|
717
|
+
return await this.#cached(`${root}\u0000branches`, async () => {
|
|
718
|
+
// `git branch --format` supports only `%(field)` placeholders — the
|
|
719
|
+
// `%x1f` escape is a pretty-format feature and would come through
|
|
720
|
+
// literally here. A literal TAB is the safe separator instead:
|
|
721
|
+
// check-ref-format forbids every ASCII control character in a refname.
|
|
722
|
+
const raw = await git(root, [
|
|
723
|
+
'branch',
|
|
724
|
+
'--all',
|
|
725
|
+
`--format=%(HEAD)\t%(refname)\t%(refname:short)\t%(upstream:short)\t%(objectname:short)`,
|
|
726
|
+
])
|
|
727
|
+
const local = []
|
|
728
|
+
const remote = []
|
|
729
|
+
for (const line of raw.split('\n')) {
|
|
730
|
+
if (line === '') continue
|
|
731
|
+
const [head, refname, short, upstream, oid] = line.split('\t')
|
|
732
|
+
if (refname.startsWith('refs/heads/')) {
|
|
733
|
+
local.push({ name: short, current: head.trim() === '*', upstream: upstream === '' ? null : upstream, oid })
|
|
734
|
+
} else if (refname.startsWith('refs/remotes/')) {
|
|
735
|
+
// Derive everything from the full refname, never from
|
|
736
|
+
// `%(refname:short)`: for the symbolic `refs/remotes/origin/HEAD`
|
|
737
|
+
// git shortens to plain `origin`, so a short-name check would let it
|
|
738
|
+
// through as a bogus branch named after the remote.
|
|
739
|
+
if (refname.endsWith('/HEAD')) continue
|
|
740
|
+
const rest = refname.slice('refs/remotes/'.length)
|
|
741
|
+
const slash = rest.indexOf('/')
|
|
742
|
+
remote.push({
|
|
743
|
+
name: rest,
|
|
744
|
+
remote: slash === -1 ? '' : rest.slice(0, slash),
|
|
745
|
+
branch: slash === -1 ? rest : rest.slice(slash + 1),
|
|
746
|
+
oid,
|
|
747
|
+
})
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return { local, remote, head: local.find((branch) => branch.current)?.name ?? null }
|
|
751
|
+
})
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* One commit's header plus the files it touched.
|
|
756
|
+
* @param root - canonical repository root.
|
|
757
|
+
* @param hash - the commit to inspect.
|
|
758
|
+
* @returns the parsed commit detail, or null when the hash resolves to nothing.
|
|
759
|
+
*/
|
|
760
|
+
async commitDetail(root, hash) {
|
|
761
|
+
return await this.#cached(`${root}\u0000commit\u0000${hash}`, async () => {
|
|
762
|
+
const raw = await git(root, [
|
|
763
|
+
'show',
|
|
764
|
+
'--name-status',
|
|
765
|
+
'--no-color',
|
|
766
|
+
'--format=%H' + FS + '%an' + FS + '%at' + FS + '%s',
|
|
767
|
+
hash,
|
|
768
|
+
])
|
|
769
|
+
return parseCommitFiles(raw)
|
|
770
|
+
})
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* The patch a single commit applied, optionally limited to one path.
|
|
775
|
+
* @param root - canonical repository root.
|
|
776
|
+
* @param hash - the commit to show.
|
|
777
|
+
* @param file - optional pathspec.
|
|
778
|
+
* @returns raw unified diff text.
|
|
779
|
+
*/
|
|
780
|
+
async show(root, hash, file) {
|
|
781
|
+
const args = ['show', '--no-color', '--no-ext-diff', '--format=', hash]
|
|
782
|
+
if (typeof file === 'string' && file !== '') args.push('--', file)
|
|
783
|
+
return await git(root, args)
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Stage paths.
|
|
788
|
+
* @param root - canonical repository root.
|
|
789
|
+
* @param files - pathspecs to stage.
|
|
790
|
+
*/
|
|
791
|
+
async stage(root, files) {
|
|
792
|
+
// `-A` so a staged deletion is recorded too, which plain `git add` handles
|
|
793
|
+
// only from git 2.x with `--all` explicitly.
|
|
794
|
+
await git(root, ['add', '-A', '--', ...files])
|
|
795
|
+
this.invalidate(root)
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Unstage paths, leaving the worktree untouched.
|
|
800
|
+
* @param root - canonical repository root.
|
|
801
|
+
* @param files - pathspecs to unstage.
|
|
802
|
+
*/
|
|
803
|
+
async unstage(root, files) {
|
|
804
|
+
// `reset` rather than `restore --staged`: it also drops a newly added file
|
|
805
|
+
// out of the index, which `restore` refuses because the path is not in HEAD.
|
|
806
|
+
await git(root, ['reset', '-q', 'HEAD', '--', ...files])
|
|
807
|
+
this.invalidate(root)
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Commit what is staged.
|
|
812
|
+
* @param root - canonical repository root.
|
|
813
|
+
* @param message - the commit message.
|
|
814
|
+
* @returns the new commit's short hash.
|
|
815
|
+
*/
|
|
816
|
+
async createCommit(root, message) {
|
|
817
|
+
await git(root, ['commit', '-m', message])
|
|
818
|
+
this.invalidate(root)
|
|
819
|
+
return (await git(root, ['rev-parse', '--short', 'HEAD'])).trim()
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Check out a ref, optionally creating a branch first.
|
|
824
|
+
*
|
|
825
|
+
* A branch name is validated by git itself (`check-ref-format --branch`)
|
|
826
|
+
* rather than by a hand-rolled pattern: the rules are long, they have changed
|
|
827
|
+
* between versions, and git is the authority on them.
|
|
828
|
+
* @param root - canonical repository root.
|
|
829
|
+
* @param ref - the branch or commit-ish to check out, or the start point.
|
|
830
|
+
* @param create - when given, create this branch at `ref` and switch to it.
|
|
831
|
+
*/
|
|
832
|
+
async checkout(root, ref, create) {
|
|
833
|
+
const target = requireRef(ref, 'ref')
|
|
834
|
+
if (create === undefined || create === null || create === '') {
|
|
835
|
+
await git(root, ['checkout', target])
|
|
836
|
+
} else {
|
|
837
|
+
const name = requireRef(create, 'branch name')
|
|
838
|
+
// Non-zero exit means git rejected the name; its own stderr is the message.
|
|
839
|
+
await git(root, ['check-ref-format', '--branch', name])
|
|
840
|
+
await git(root, ['checkout', '-b', name, target])
|
|
841
|
+
}
|
|
842
|
+
this.invalidate(root)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Fetch and integrate the current branch's upstream.
|
|
847
|
+
*
|
|
848
|
+
* Reports whether HEAD actually moved rather than parsing git's message: the
|
|
849
|
+
* wording is localized and has changed between versions, while the two hashes
|
|
850
|
+
* are unambiguous.
|
|
851
|
+
* @param root - canonical repository root.
|
|
852
|
+
* @param strategy - one of {@link PULL_STRATEGIES}.
|
|
853
|
+
* @returns `{changed, before, after, output}`.
|
|
854
|
+
*/
|
|
855
|
+
async pull(root, strategy) {
|
|
856
|
+
const args = PULL_STRATEGIES[strategy]
|
|
857
|
+
if (args === undefined) {
|
|
858
|
+
throw new Error(
|
|
859
|
+
`unknown pull strategy ${JSON.stringify(strategy)} (expected one of ${Object.keys(PULL_STRATEGIES).join(', ')})`,
|
|
860
|
+
)
|
|
861
|
+
}
|
|
862
|
+
const head = async () =>
|
|
863
|
+
await git(root, ['rev-parse', 'HEAD'], { allowExitCodes: [128] })
|
|
864
|
+
.then((value) => value.trim())
|
|
865
|
+
.catch(() => '')
|
|
866
|
+
const before = await head()
|
|
867
|
+
const output = await git(root, ['pull', ...args], { timeoutMs: GIT_NETWORK_TIMEOUT_MS })
|
|
868
|
+
const after = await head()
|
|
869
|
+
this.invalidate(root)
|
|
870
|
+
return {
|
|
871
|
+
changed: before !== after,
|
|
872
|
+
before: before.slice(0, 7),
|
|
873
|
+
after: after.slice(0, 7),
|
|
874
|
+
output:
|
|
875
|
+
output.length > MAX_NETWORK_OUTPUT ? `${output.slice(0, MAX_NETWORK_OUTPUT)}\n… output truncated` : output,
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Everything that changed since a marker commit, committed or not.
|
|
881
|
+
*
|
|
882
|
+
* The marker is compared against the WORKING TREE rather than HEAD, so an
|
|
883
|
+
* agent that left changes uncommitted is not hidden by a review view that
|
|
884
|
+
* only looks at history.
|
|
885
|
+
* @param root - canonical repository root.
|
|
886
|
+
* @param since - the marker commit-ish.
|
|
887
|
+
* @returns commits since the marker, per-file stats, and totals.
|
|
888
|
+
*/
|
|
889
|
+
async review(root, since) {
|
|
890
|
+
const marker = requireRef(since, 'since')
|
|
891
|
+
// A rebase can delete the marker commit; resolving first turns that into a
|
|
892
|
+
// clear error instead of an empty-looking review.
|
|
893
|
+
await git(root, ['rev-parse', '--verify', `${marker}^{commit}`])
|
|
894
|
+
const [logRaw, nameRaw, numRaw, headRaw] = await Promise.all([
|
|
895
|
+
git(root, [
|
|
896
|
+
'log',
|
|
897
|
+
'--topo-order',
|
|
898
|
+
'--max-count=500',
|
|
899
|
+
`--pretty=format:%H${FS}%P${FS}%an${FS}%ae${FS}%at${FS}%s${RS}`,
|
|
900
|
+
`${marker}..HEAD`,
|
|
901
|
+
]),
|
|
902
|
+
git(root, ['diff', '--name-status', '-z', '--find-renames', marker]),
|
|
903
|
+
git(root, ['diff', '--numstat', '-z', '--find-renames', marker]),
|
|
904
|
+
git(root, ['rev-parse', '--short', 'HEAD']),
|
|
905
|
+
])
|
|
906
|
+
const statuses = parseNameStatus(nameRaw)
|
|
907
|
+
const files = parseNumstat(numRaw).map((entry) => ({
|
|
908
|
+
...entry,
|
|
909
|
+
status: statuses.get(entry.path)?.status ?? null,
|
|
910
|
+
}))
|
|
911
|
+
const totals = files.reduce(
|
|
912
|
+
(sum, file) => ({
|
|
913
|
+
files: sum.files + 1,
|
|
914
|
+
added: sum.added + (file.added ?? 0),
|
|
915
|
+
deleted: sum.deleted + (file.deleted ?? 0),
|
|
916
|
+
binary: sum.binary + (file.binary ? 1 : 0),
|
|
917
|
+
}),
|
|
918
|
+
{ files: 0, added: 0, deleted: 0, binary: 0 },
|
|
919
|
+
)
|
|
920
|
+
return { marker, head: headRaw.trim(), commits: parseLog(logRaw), files, totals }
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Push the current branch, publishing it first when it has no upstream.
|
|
925
|
+
*
|
|
926
|
+
* Reports the upstream's hash before and after rather than parsing git's
|
|
927
|
+
* wording, for the same reason as {@link pull}: messages are localized while
|
|
928
|
+
* a hash is not. Before the first push of a branch there is no upstream at
|
|
929
|
+
* all, which reads as an empty "before" and therefore as a change.
|
|
930
|
+
* @param root - canonical repository root.
|
|
931
|
+
* @param setUpstream - create/point the upstream as part of the push.
|
|
932
|
+
* @returns `{branch, remote, published, changed, before, after, output}`.
|
|
933
|
+
*/
|
|
934
|
+
async push(root, setUpstream) {
|
|
935
|
+
const branch = (await git(root, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()
|
|
936
|
+
if (branch === 'HEAD') throw new Error('cannot push: HEAD is detached')
|
|
937
|
+
const args = ['push']
|
|
938
|
+
let remote = null
|
|
939
|
+
if (setUpstream === true) {
|
|
940
|
+
remote = await this.#publishRemote(root)
|
|
941
|
+
args.push('--set-upstream', remote, branch)
|
|
942
|
+
}
|
|
943
|
+
const before = await this.#upstreamHash(root)
|
|
944
|
+
const output = await git(root, args, { timeoutMs: GIT_NETWORK_TIMEOUT_MS })
|
|
945
|
+
const after = await this.#upstreamHash(root)
|
|
946
|
+
this.invalidate(root)
|
|
947
|
+
return {
|
|
948
|
+
branch,
|
|
949
|
+
remote,
|
|
950
|
+
published: setUpstream === true,
|
|
951
|
+
changed: before !== after,
|
|
952
|
+
before: before.slice(0, 7),
|
|
953
|
+
after: after.slice(0, 7),
|
|
954
|
+
output:
|
|
955
|
+
output.length > MAX_NETWORK_OUTPUT
|
|
956
|
+
? `${output.slice(0, MAX_NETWORK_OUTPUT)}\n… output truncated`
|
|
957
|
+
: output,
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* The hash the branch's upstream points at, or an empty string when the
|
|
963
|
+
* branch has no upstream yet.
|
|
964
|
+
* @param root - canonical repository root.
|
|
965
|
+
* @returns the hash, or ''.
|
|
966
|
+
*/
|
|
967
|
+
async #upstreamHash(root) {
|
|
968
|
+
try {
|
|
969
|
+
return (await git(root, ['rev-parse', '@{upstream}'])).trim()
|
|
970
|
+
} catch {
|
|
971
|
+
return ''
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Choose the remote to publish a branch to.
|
|
977
|
+
*
|
|
978
|
+
* `origin` is the overwhelming convention, and a single remote is
|
|
979
|
+
* unambiguous. Anything else is refused instead of guessed: pushing to the
|
|
980
|
+
* wrong remote is not something to discover afterwards.
|
|
981
|
+
* @param root - canonical repository root.
|
|
982
|
+
* @returns the remote name.
|
|
983
|
+
*/
|
|
984
|
+
async #publishRemote(root) {
|
|
985
|
+
const remotes = (await git(root, ['remote']))
|
|
986
|
+
.split('\n')
|
|
987
|
+
.map((name) => name.trim())
|
|
988
|
+
.filter((name) => name !== '')
|
|
989
|
+
if (remotes.includes('origin')) return 'origin'
|
|
990
|
+
if (remotes.length === 1) return remotes[0]
|
|
991
|
+
if (remotes.length === 0) throw new Error('no remote to publish to')
|
|
992
|
+
throw new Error(`several remotes and none is "origin"; publish from a terminal (${remotes.join(', ')})`)
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Drop every cached entry for one repository.
|
|
997
|
+
*
|
|
998
|
+
* Public because a write through this API must not wait for the filesystem
|
|
999
|
+
* watcher's debounce to expire: the panel refreshes immediately after its own
|
|
1000
|
+
* mutation and would otherwise read the state it just replaced.
|
|
1001
|
+
* @param root - canonical repository root.
|
|
1002
|
+
*/
|
|
1003
|
+
invalidate(root) {
|
|
1004
|
+
this.#invalidate(root)
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Read-through cache keyed by root and query shape.
|
|
1009
|
+
* @param key - cache key.
|
|
1010
|
+
* @param produce - the git call to run on a miss.
|
|
1011
|
+
* @returns the cached or freshly produced value.
|
|
1012
|
+
*/
|
|
1013
|
+
async #cached(key, produce) {
|
|
1014
|
+
const hit = this.#cache.get(key)
|
|
1015
|
+
if (hit !== undefined) return hit
|
|
1016
|
+
const value = await produce()
|
|
1017
|
+
this.#cache.set(key, value)
|
|
1018
|
+
return value
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Drop every cached entry for one repository.
|
|
1023
|
+
* @param root - canonical repository root.
|
|
1024
|
+
*/
|
|
1025
|
+
#invalidate(root) {
|
|
1026
|
+
const prefix = `${root}\u0000`
|
|
1027
|
+
for (const key of [...this.#cache.keys()]) {
|
|
1028
|
+
if (key.startsWith(prefix)) this.#cache.delete(key)
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* Where a repository keeps its git directory.
|
|
1034
|
+
*
|
|
1035
|
+
* `<root>/.git` is a directory in a plain repository, but it is a FILE in a
|
|
1036
|
+
* linked worktree (`git worktree add`) and in a submodule — a one-line
|
|
1037
|
+
* pointer to the real directory (`…/.git/worktrees/<name>`). Watching that
|
|
1038
|
+
* file means watching a file that never changes: a checkout in the worktree
|
|
1039
|
+
* rewrites `<gitdir>/HEAD`, far away, and the cache would keep serving the
|
|
1040
|
+
* old branch forever.
|
|
1041
|
+
*
|
|
1042
|
+
* git itself knows the answer, and knows it for every layout —
|
|
1043
|
+
* `--separate-git-dir`, `core.worktree`, submodules, worktrees.
|
|
1044
|
+
* @param root - canonical repository root.
|
|
1045
|
+
* @returns the git directory, or the conventional path when git cannot say.
|
|
1046
|
+
*/
|
|
1047
|
+
async #gitDir(root) {
|
|
1048
|
+
try {
|
|
1049
|
+
const out = await git(root, ['rev-parse', '--absolute-git-dir'])
|
|
1050
|
+
const dir = out.trim()
|
|
1051
|
+
if (dir !== '') return dir
|
|
1052
|
+
} catch {
|
|
1053
|
+
/* older git, or an exotic layout: fall back to the conventional path */
|
|
1054
|
+
}
|
|
1055
|
+
return join(root, '.git')
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Attach a best-effort watcher to a repository's git directory.
|
|
1060
|
+
*
|
|
1061
|
+
* Non-recursive on purpose: `HEAD`, `index` and the mtime of `refs` all sit
|
|
1062
|
+
* at this level, which is what a commit or checkout touches. Failing to
|
|
1063
|
+
* watch is not fatal — it degrades the panel to refresh-on-demand, which is
|
|
1064
|
+
* why the catch is silent.
|
|
1065
|
+
* @param root - canonical repository root.
|
|
1066
|
+
*/
|
|
1067
|
+
async #watch(root) {
|
|
1068
|
+
if (this.#watchers.has(root)) return
|
|
1069
|
+
try {
|
|
1070
|
+
const dir = await this.#gitDir(root)
|
|
1071
|
+
this.#watchers.set(
|
|
1072
|
+
root,
|
|
1073
|
+
watch(dir, { persistent: false }, () => this.#scheduleInvalidate(root)),
|
|
1074
|
+
)
|
|
1075
|
+
} catch {
|
|
1076
|
+
/* not watchable (permissions, exotic filesystem): fall back to manual refresh */
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Debounce the write burst one git operation emits.
|
|
1082
|
+
* @param root - canonical repository root.
|
|
1083
|
+
*/
|
|
1084
|
+
#scheduleInvalidate(root) {
|
|
1085
|
+
const pending = this.#timers.get(root)
|
|
1086
|
+
if (pending !== undefined) clearTimeout(pending)
|
|
1087
|
+
this.#timers.set(
|
|
1088
|
+
root,
|
|
1089
|
+
setTimeout(() => {
|
|
1090
|
+
this.#timers.delete(root)
|
|
1091
|
+
this.#invalidate(root)
|
|
1092
|
+
}, WATCH_DEBOUNCE_MS),
|
|
1093
|
+
)
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** Release every watcher, timer and cache entry. */
|
|
1097
|
+
dispose() {
|
|
1098
|
+
for (const timer of this.#timers.values()) clearTimeout(timer)
|
|
1099
|
+
this.#timers.clear()
|
|
1100
|
+
for (const watcher of this.#watchers.values()) {
|
|
1101
|
+
try {
|
|
1102
|
+
watcher.close()
|
|
1103
|
+
} catch {
|
|
1104
|
+
/* already closed */
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
this.#watchers.clear()
|
|
1108
|
+
this.#cache.clear()
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Dispatch one request under {@link ROUTE_PREFIX}.
|
|
1114
|
+
* @param ctx - the plugin context, for logging.
|
|
1115
|
+
* @param service - the git state holder.
|
|
1116
|
+
* @param req - the node request.
|
|
1117
|
+
* @param res - the node response.
|
|
1118
|
+
*/
|
|
1119
|
+
async function handle(ctx, service, req, res) {
|
|
1120
|
+
const method = req.method ?? 'GET'
|
|
1121
|
+
if (method !== 'GET' && method !== 'POST') {
|
|
1122
|
+
send(res, 405, { ok: false, error: `method ${method} not allowed` })
|
|
1123
|
+
return
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
let url
|
|
1127
|
+
try {
|
|
1128
|
+
url = new URL(req.url ?? '/', 'http://localhost')
|
|
1129
|
+
} catch {
|
|
1130
|
+
send(res, 400, { ok: false, error: 'malformed request url' })
|
|
1131
|
+
return
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const route = url.pathname.slice(ROUTE_PREFIX.length) || '/'
|
|
1135
|
+
const params = url.searchParams
|
|
1136
|
+
|
|
1137
|
+
try {
|
|
1138
|
+
if (method === 'GET') {
|
|
1139
|
+
switch (route) {
|
|
1140
|
+
case '/health':
|
|
1141
|
+
send(res, 200, { ok: true, data: { service: name, pid: process.pid, writes: true } })
|
|
1142
|
+
return
|
|
1143
|
+
|
|
1144
|
+
case '/status': {
|
|
1145
|
+
const root = await service.resolveRoot(params.get('root') ?? undefined)
|
|
1146
|
+
send(res, 200, { ok: true, data: { root, ...(await service.status(root)) } })
|
|
1147
|
+
return
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
case '/log': {
|
|
1151
|
+
const root = await service.resolveRoot(params.get('root') ?? undefined)
|
|
1152
|
+
const commits = await service.log(root, params.get('limit'), params.get('ref') ?? undefined)
|
|
1153
|
+
send(res, 200, { ok: true, data: { root, ref: params.get('ref') ?? null, commits } })
|
|
1154
|
+
return
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
case '/diff': {
|
|
1158
|
+
const root = await service.resolveRoot(params.get('root') ?? undefined)
|
|
1159
|
+
const staged = params.get('staged') === '1'
|
|
1160
|
+
const untracked = params.get('untracked') === '1'
|
|
1161
|
+
const since = params.get('since')
|
|
1162
|
+
const text = await service.diff(root, params.get('file'), staged, untracked, since)
|
|
1163
|
+
send(res, 200, {
|
|
1164
|
+
ok: true,
|
|
1165
|
+
data: { root, file: params.get('file'), staged, untracked, since: since ?? null, text },
|
|
1166
|
+
})
|
|
1167
|
+
return
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
case '/branches': {
|
|
1171
|
+
const root = await service.resolveRoot(params.get('root') ?? undefined)
|
|
1172
|
+
send(res, 200, { ok: true, data: { root, ...(await service.branches(root)) } })
|
|
1173
|
+
return
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
case '/review': {
|
|
1177
|
+
const root = await service.resolveRoot(params.get('root') ?? undefined)
|
|
1178
|
+
const since = params.get('since')
|
|
1179
|
+
if (since === null || since === '') throw new Error('review needs a since marker')
|
|
1180
|
+
send(res, 200, { ok: true, data: { root, ...(await service.review(root, since)) } })
|
|
1181
|
+
return
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
case '/commit': {
|
|
1185
|
+
const root = await service.resolveRoot(params.get('root') ?? undefined)
|
|
1186
|
+
const detail = await service.commitDetail(root, params.get('hash') ?? 'HEAD')
|
|
1187
|
+
if (detail === null) {
|
|
1188
|
+
send(res, 404, { ok: false, error: `no commit ${JSON.stringify(params.get('hash'))}` })
|
|
1189
|
+
return
|
|
1190
|
+
}
|
|
1191
|
+
send(res, 200, { ok: true, data: { root, ...detail } })
|
|
1192
|
+
return
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
case '/show': {
|
|
1196
|
+
const root = await service.resolveRoot(params.get('root') ?? undefined)
|
|
1197
|
+
const text = await service.show(root, params.get('hash') ?? 'HEAD', params.get('file'))
|
|
1198
|
+
send(res, 200, { ok: true, data: { root, text } })
|
|
1199
|
+
return
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
default:
|
|
1203
|
+
send(res, 404, { ok: false, error: `unknown git-api route ${JSON.stringify(route)}` })
|
|
1204
|
+
return
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// ── writes ────────────────────────────────────────────────────────────────
|
|
1209
|
+
// Every write is refused without the custom header, so a page the user has
|
|
1210
|
+
// open cannot drive git through this loopback route table.
|
|
1211
|
+
assertNotCrossSite(req)
|
|
1212
|
+
const body = await readJsonBody(req)
|
|
1213
|
+
|
|
1214
|
+
switch (route) {
|
|
1215
|
+
case '/stage': {
|
|
1216
|
+
const root = await service.resolveRoot(typeof body.root === 'string' ? body.root : undefined)
|
|
1217
|
+
const files = requireFiles(body.files)
|
|
1218
|
+
await service.stage(root, files)
|
|
1219
|
+
send(res, 200, { ok: true, data: { root, staged: files } })
|
|
1220
|
+
return
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
case '/unstage': {
|
|
1224
|
+
const root = await service.resolveRoot(typeof body.root === 'string' ? body.root : undefined)
|
|
1225
|
+
const files = requireFiles(body.files)
|
|
1226
|
+
await service.unstage(root, files)
|
|
1227
|
+
send(res, 200, { ok: true, data: { root, unstaged: files } })
|
|
1228
|
+
return
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
case '/checkout': {
|
|
1232
|
+
const root = await service.resolveRoot(typeof body.root === 'string' ? body.root : undefined)
|
|
1233
|
+
const ref = requireRef(body.ref, 'ref')
|
|
1234
|
+
const create = body.create === undefined || body.create === null ? undefined : body.create
|
|
1235
|
+
await service.checkout(root, ref, create)
|
|
1236
|
+
send(res, 200, { ok: true, data: { root, ref, create: create ?? null } })
|
|
1237
|
+
return
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
case '/pull': {
|
|
1241
|
+
const root = await service.resolveRoot(typeof body.root === 'string' ? body.root : undefined)
|
|
1242
|
+
const strategy = body.strategy === undefined || body.strategy === null ? 'ff-only' : body.strategy
|
|
1243
|
+
const result = await service.pull(root, strategy)
|
|
1244
|
+
send(res, 200, { ok: true, data: { root, strategy, ...result } })
|
|
1245
|
+
return
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
case '/push': {
|
|
1249
|
+
const root = await service.resolveRoot(typeof body.root === 'string' ? body.root : undefined)
|
|
1250
|
+
const setUpstream = body.setUpstream === true
|
|
1251
|
+
const result = await service.push(root, setUpstream)
|
|
1252
|
+
send(res, 200, { ok: true, data: { root, ...result } })
|
|
1253
|
+
return
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
case '/hunks': {
|
|
1257
|
+
const root = await service.resolveRoot(typeof body.root === 'string' ? body.root : undefined)
|
|
1258
|
+
const file = typeof body.file === 'string' ? body.file : ''
|
|
1259
|
+
if (file === '') throw new Error('hunks needs a file')
|
|
1260
|
+
const staged = body.staged === true
|
|
1261
|
+
await service.applyHunks(root, file, body.hunks, staged)
|
|
1262
|
+
send(res, 200, { ok: true, data: { root, file, staged, hunks: body.hunks } })
|
|
1263
|
+
return
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
case '/commit': {
|
|
1267
|
+
const root = await service.resolveRoot(typeof body.root === 'string' ? body.root : undefined)
|
|
1268
|
+
const message = typeof body.message === 'string' ? body.message.trim() : ''
|
|
1269
|
+
if (message === '') throw new Error('commit message must not be empty')
|
|
1270
|
+
const hash = await service.createCommit(root, message)
|
|
1271
|
+
send(res, 200, { ok: true, data: { root, hash } })
|
|
1272
|
+
return
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
default:
|
|
1276
|
+
send(res, 404, { ok: false, error: `unknown git-api write route ${JSON.stringify(route)}` })
|
|
1277
|
+
return
|
|
1278
|
+
}
|
|
1279
|
+
} catch (error) {
|
|
1280
|
+
// Caller-caused problems (bad path, not a repo, outside allowedRoots, an
|
|
1281
|
+
// empty message, a rejected cross-site write) are 400s with the git
|
|
1282
|
+
// diagnostic attached; the panel shows that text verbatim.
|
|
1283
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
1284
|
+
ctx.logger?.warn?.(`dsh-git: ${method} ${route} failed: ${message}`)
|
|
1285
|
+
send(res, 400, { ok: false, error: message })
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/**
|
|
1290
|
+
* Plugin body: mount one prefix route over a {@link GitService}.
|
|
1291
|
+
* @param ctx - the plugin context.
|
|
1292
|
+
* @param config - the row's config from `cordis.patch.yml`.
|
|
1293
|
+
*/
|
|
1294
|
+
export function apply(ctx, config) {
|
|
1295
|
+
// Resolved lazily and defensively: the workspace registry is optional, and a
|
|
1296
|
+
// deployment without it simply falls back to `allowedRoots`.
|
|
1297
|
+
const knownWorkspaces = () => {
|
|
1298
|
+
// Registered as `workspaceRegistry` — that is how
|
|
1299
|
+
// `dsh-api-workspace-controller` reaches it. Deliberately no fallback to a
|
|
1300
|
+
// guessed second name: an unverified alias would make it ambiguous which
|
|
1301
|
+
// one is actually in play when the allowlist behaves unexpectedly.
|
|
1302
|
+
const registry = ctx.get?.('workspaceRegistry')
|
|
1303
|
+
if (registry === undefined || registry === null || typeof registry.list !== 'function') return []
|
|
1304
|
+
return registry.list().map((workspace) => workspace?.path)
|
|
1305
|
+
}
|
|
1306
|
+
const service = new GitService(config ?? {}, knownWorkspaces)
|
|
1307
|
+
ctx.effect(() => () => service.dispose(), 'dsh-git: service lifetime')
|
|
1308
|
+
ctx.effect(
|
|
1309
|
+
() =>
|
|
1310
|
+
ctx.webServer.register({
|
|
1311
|
+
kind: 'prefix',
|
|
1312
|
+
path: ROUTE_PREFIX,
|
|
1313
|
+
handler: (req, res) => {
|
|
1314
|
+
void handle(ctx, service, req, res)
|
|
1315
|
+
},
|
|
1316
|
+
}),
|
|
1317
|
+
'dsh-git: route table',
|
|
1318
|
+
)
|
|
1319
|
+
ctx.logger?.info?.(`dsh-git: mounted ${ROUTE_PREFIX}`)
|
|
1320
|
+
}
|