dsh-git-idea 0.1.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/lib/index.js ADDED
@@ -0,0 +1,2701 @@
1
+ /* GENERATED by build-package.mjs from 13 fragments under src/ — edit those, not this file. */
2
+ /* ── the real-package Host half ──
3
+
4
+ The fragments under src/host/ were written for the dynamic Cordis bridge:
5
+ that realm handed the plugin a \`harness\` (\`defineTool\` / \`registerTool\` /
6
+ \`handle\`) and a façade \`ctx\`. A real package gets neither, so this prelude
7
+ supplies the same three ways to speak over the services a real \`ctx\` has.
8
+ The body after it is the same text the dynamic bridge loads — one body, two
9
+ builds — and host-post.js exports the plugin object. */
10
+
11
+ import { defineTool } from '@deepseek-ai/dsh-tools'
12
+
13
+ /* The one route the browser half calls: same origin as the page, so no CORS.
14
+ The same-origin check below is what keeps another page on loopback from
15
+ driving git in this reader's working directory. */
16
+ const RPC_PATH = '/dsh-git-idea/rpc'
17
+ const RPC_MAX_BYTES = 1048576
18
+
19
+ /* Every handler the Client registered through \`harness.handle\`, keyed by the
20
+ method string it passed to \`host.call\`. */
21
+ const rpcHandlers = new Map()
22
+
23
+ function detail(error) {
24
+ return error != null && error.message !== undefined ? String(error.message) : String(error)
25
+ }
26
+
27
+ function sendJson(response, status, payload) {
28
+ response.writeHead(status, {
29
+ 'cache-control': 'no-store',
30
+ 'content-type': 'application/json; charset=utf-8',
31
+ })
32
+ response.end(JSON.stringify(payload))
33
+ }
34
+
35
+ /* Required on every POST: the port is reachable from any page on this machine,
36
+ and these methods run git in the reader's own working directory. */
37
+ function sameOrigin(request) {
38
+ const origin = request.headers.origin
39
+ const host = request.headers.host
40
+ if (origin === undefined || host === undefined) return false
41
+ try {
42
+ return new URL(origin).host === host
43
+ } catch (error) {
44
+ return false
45
+ }
46
+ }
47
+
48
+ async function readJsonBody(request) {
49
+ const chunks = []
50
+ let size = 0
51
+ for await (const chunk of request) {
52
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
53
+ size += buffer.length
54
+ if (size > RPC_MAX_BYTES) throw new Error('request body too large')
55
+ chunks.push(buffer)
56
+ }
57
+ const text = Buffer.concat(chunks).toString('utf8')
58
+ return text.length === 0 ? null : JSON.parse(text)
59
+ }
60
+
61
+ /* The dynamic bridge refused an unregistered method with "... is not
62
+ registered", and 10-state.js retries exactly that sentence while the Host
63
+ half is still coming up. The 404 below keeps the same words, so that retry
64
+ survives the move to a real package. */
65
+ async function rpcRoute(request, response) {
66
+ if (request.method !== 'POST') {
67
+ sendJson(response, 405, { ok: false, error: 'rpc is POST only' })
68
+ return
69
+ }
70
+ if (!sameOrigin(request)) {
71
+ sendJson(response, 403, { ok: false, error: 'same-origin requests only' })
72
+ return
73
+ }
74
+ let body
75
+ try {
76
+ body = await readJsonBody(request)
77
+ } catch (error) {
78
+ sendJson(response, 400, { ok: false, error: detail(error) })
79
+ return
80
+ }
81
+ const method = body != null && typeof body.method === 'string' ? body.method : ''
82
+ const handler = rpcHandlers.get(method)
83
+ if (handler === undefined) {
84
+ sendJson(response, 404, { ok: false, error: method + ' is not registered' })
85
+ return
86
+ }
87
+ try {
88
+ const value = await handler(body.payload)
89
+ sendJson(response, 200, { ok: true, value: value === undefined ? null : value })
90
+ } catch (error) {
91
+ sendJson(response, 200, { ok: false, error: detail(error) })
92
+ }
93
+ }
94
+
95
+ const harness = {
96
+ defineTool: function (options) { return defineTool(options) },
97
+ registerTool: function (ctx, tool) { return ctx.tools.register(tool) },
98
+ handle: function (method, handler) {
99
+ rpcHandlers.set(method, handler)
100
+ return function () { rpcHandlers.delete(method) }
101
+ },
102
+ }
103
+
104
+ export const name = 'dsh-git-idea'
105
+
106
+ const plugin = (function () {
107
+ return {
108
+ apply(ctx) {
109
+
110
+ const shell = ctx.get('shell')
111
+ if (shell === undefined) {
112
+ console.error('git plugin: the shell Service is unavailable; no tools registered')
113
+ return
114
+ }
115
+
116
+ /* ─────────────── primitives ─────────────── */
117
+
118
+ function shq(value) {
119
+ return "'" + String(value).replace(/'/g, "'\\''") + "'"
120
+ }
121
+
122
+ function isStr(value) {
123
+ return typeof value === 'string'
124
+ }
125
+
126
+ /* One field of a record git printed as a separated line. A field git had nothing
127
+ to put in is simply absent from the split, and every reader wants the same
128
+ thing there: the empty string, never `undefined` leaking into a reply the
129
+ Client will render. Longhand this is `fields[3] === undefined ? '' : fields[3]`
130
+ — a forty-two times repeated question, asked once here. */
131
+ function field(split, index) {
132
+ const value = split[index]
133
+ return value === undefined ? '' : value
134
+ }
135
+
136
+ function sessionCwd(exec) {
137
+ if (exec == null || exec.agent == null) return undefined
138
+ const session = exec.agent.session
139
+ const header = session != null ? session.header : undefined
140
+ const cwd = header != null ? header.cwd : undefined
141
+ return isStr(cwd) && cwd.length > 0 ? cwd : undefined
142
+ }
143
+
144
+ function workdirFor(args, exec) {
145
+ if (args != null && isStr(args.repo) && args.repo.trim().length > 0) return args.repo.trim()
146
+ return sessionCwd(exec)
147
+ }
148
+
149
+ function here(args, exec) {
150
+ const cwd = workdirFor(args, exec)
151
+ return cwd === undefined ? null : cwd
152
+ }
153
+
154
+ /* ── whose sandbox these commands run under ──
155
+
156
+ A shell call that names no policy gets the *deployment* default, not the
157
+ session's. Measured on this deployment: the default is `workspace-write` rooted
158
+ at the deployment's own directory (/mnt/c/Users/mayou here) while the session is
159
+ `danger-full-access` — so every writing git command failed with "Permission
160
+ denied" on `.git/index.lock` for any repository outside that one directory,
161
+ while reads were fine and it looked like git refusing to work.
162
+
163
+ The session's mode and its cwd are exactly what the reader's own commands run
164
+ under, so they are what this plugin asks for: `sessions` says which session,
165
+ `sandboxPolicy` says what that session resolved to, and a session that is
166
+ read-only keeps this plugin read-only. Without either service the request goes
167
+ out unchanged and the shell layer falls back as before. */
168
+ function sandboxFor(args, exec) {
169
+ const policy = ctx.get('sandboxPolicy')
170
+ if (policy === undefined) return undefined
171
+ let session = null
172
+ try {
173
+ if (exec != null && exec.agent != null && exec.agent.session != null) {
174
+ session = exec.agent.session
175
+ } else if (args != null && isStr(args.sessionId) && args.sessionId.length > 0) {
176
+ const sessions = ctx.get('sessions')
177
+ if (sessions !== undefined) session = sessions.get(args.sessionId)
178
+ }
179
+ if (session == null) return undefined
180
+ const resolved = policy.resolve({ session: session })
181
+ return resolved == null ? undefined : resolved
182
+ } catch (error) {
183
+ console.error('dsh-git-idea: could not resolve this session\'s sandbox policy', String(error))
184
+ return undefined
185
+ }
186
+ }
187
+
188
+ async function invoke(command, args, exec, options) {
189
+ const opts = options == null ? {} : options
190
+ const request = {
191
+ command: command,
192
+ timeoutMs: typeof opts.timeoutMs === 'number' && opts.timeoutMs > 0 ? opts.timeoutMs : 120000,
193
+ stdoutMaxBytes: typeof opts.maxBytes === 'number' ? opts.maxBytes : 1048576,
194
+ }
195
+ const workdir = workdirFor(args, exec)
196
+ if (workdir !== undefined) request.workdir = workdir
197
+ if (isStr(opts.stdin)) request.stdin = opts.stdin
198
+ const sandbox = sandboxFor(args, exec)
199
+ if (sandbox !== undefined) request.sandboxPolicy = sandbox
200
+ const raw = await shell.run(shell.resolve(request))
201
+ const out = raw.stdout == null ? null : raw.stdout
202
+ const err = raw.stderr == null ? null : raw.stderr
203
+ return {
204
+ exitCode: raw.exitCode == null ? null : raw.exitCode,
205
+ stdout: out !== null && isStr(out.text) ? out.text : '',
206
+ stderr: err !== null && isStr(err.text) ? err.text : '',
207
+ truncated: (out !== null && out.truncated === true) || (err !== null && err.truncated === true),
208
+ spillPath: out !== null && isStr(out.spillPath) ? out.spillPath : null,
209
+ timedOut: raw.timedOut === true,
210
+ aborted: raw.aborted === true,
211
+ sandboxDenied: raw.sandbox != null && raw.sandbox.denied === true,
212
+ cwd: workdir === undefined ? null : workdir,
213
+ }
214
+ }
215
+
216
+ /* The three wrappers differ only in what they put in front of the command: the
217
+ package prefix is the whole of the difference, so it is the only argument. */
218
+ async function shellGit(prefix, args, argv, exec, options) {
219
+ const result = await invoke(prefix + 'git ' + argv.map(shq).join(' '), args, exec, options)
220
+ result.command = 'git ' + argv.join(' ')
221
+ result.ok = result.exitCode === 0
222
+ return result
223
+ }
224
+
225
+ async function git(args, argv, exec, options) {
226
+ return await shellGit('', args, argv, exec, options)
227
+ }
228
+
229
+ /* Network commands must never sit waiting for a credential prompt: the panel has
230
+ no terminal to answer one, so the call would hang until its timeout fires. */
231
+ async function gitNet(args, argv, exec, options) {
232
+ return await shellGit('GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=true ', args, argv, exec, options)
233
+ }
234
+
235
+ /* for-each-ref's %(upstream:track) is the one atom git translates. The switcher
236
+ shows the numbers out of it, so the words around them have to be predictable
237
+ rather than whatever locale the machine happens to use. */
238
+ async function gitC(args, argv, exec, options) {
239
+ return await shellGit('LC_ALL=C ', args, argv, exec, options)
240
+ }
241
+
242
+ /* ─────────────── safety classification ─────────────── */
243
+
244
+ const READ_ONLY_SUBCOMMANDS = [
245
+ 'status', 'diff', 'log', 'show', 'blame', 'grep', 'shortlog', 'describe',
246
+ 'rev-parse', 'rev-list', 'ls-files', 'ls-tree', 'cat-file', 'for-each-ref',
247
+ 'show-ref', 'diff-tree', 'diff-index', 'diff-files', 'merge-base', 'name-rev',
248
+ 'symbolic-ref', 'var', 'version', 'check-ignore', 'check-attr', 'whatchanged',
249
+ 'range-diff', 'cherry', 'fsck', 'count-objects', 'verify-commit', 'verify-tag',
250
+ ]
251
+
252
+ const PROTECTED_BRANCHES = ['main', 'master']
253
+
254
+ function scanArgv(argv) {
255
+ let index = 0
256
+ while (index < argv.length) {
257
+ const token = argv[index]
258
+ if (token === '-C' || token === '-c' || token === '--git-dir' || token === '--work-tree'
259
+ || token === '--namespace' || token === '--exec-path' || token === '--config-env') {
260
+ index += 2
261
+ continue
262
+ }
263
+ if (isStr(token) && token.length > 1 && token.charAt(0) === '-') {
264
+ index += 1
265
+ continue
266
+ }
267
+ break
268
+ }
269
+ return { sub: argv[index], rest: argv.slice(index + 1) }
270
+ }
271
+
272
+ function hasAny(list, names) {
273
+ for (let i = 0; i < names.length; i += 1) if (list.indexOf(names[i]) >= 0) return true
274
+ return false
275
+ }
276
+
277
+ function shortFlag(list, letters) {
278
+ for (let i = 0; i < list.length; i += 1) {
279
+ const token = list[i]
280
+ if (!isStr(token) || token.length < 2 || token.charAt(0) !== '-' || token.charAt(1) === '-') continue
281
+ const body = token.slice(1)
282
+ for (let j = 0; j < letters.length; j += 1) if (body.indexOf(letters[j]) >= 0) return letters[j]
283
+ }
284
+ return null
285
+ }
286
+
287
+ function refspecTargetsProtected(refspec) {
288
+ let spec = refspec.charAt(0) === '+' ? refspec.slice(1) : refspec
289
+ const colon = spec.lastIndexOf(':')
290
+ if (colon >= 0) spec = spec.slice(colon + 1)
291
+ return isProtectedBranch(spec)
292
+ }
293
+
294
+ /* "main", "heads/main" and "refs/heads/main" are one ref written three ways, and
295
+ git accepts all three. Only the longest spelling was recognised here, so a
296
+ force push written `-f origin heads/main` classified as merely destructive
297
+ instead of forbidden — the rule read as if it held while the ref it names went
298
+ through. */
299
+ function bareBranchName(name) {
300
+ let spec = name
301
+ if (spec.indexOf('refs/') === 0) spec = spec.slice('refs/'.length)
302
+ if (spec.indexOf('heads/') === 0) spec = spec.slice('heads/'.length)
303
+ return spec
304
+ }
305
+
306
+ function isProtectedBranch(name) {
307
+ return PROTECTED_BRANCHES.indexOf(bareBranchName(name)) >= 0
308
+ }
309
+
310
+ /* ── a value is not an option ──
311
+
312
+ The structured tools hand caller strings straight into git's argv: a revision,
313
+ a path, a remote, a branch name. A string that starts with "-" is not that
314
+ value any more — git reads it as an option and the tool does something else
315
+ entirely. Measured on this deployment: `git_sync` with branch "--force" ran
316
+ `git push origin --force` (a force push with no confirmation), `git_log` with
317
+ ref "--output=/tmp/x" wrote an empty log to that path and returned nothing, and
318
+ `git_branch` with name "--force" ran `git branch --force`. The escape hatch
319
+ has a classifier for this because its argv is open-ended; the named arguments
320
+ here only need the one rule. */
321
+ function optionLike(fields) {
322
+ for (let i = 0; i < fields.length; i += 1) {
323
+ const value = fields[i][1]
324
+ if (isStr(value) && value.length > 0 && value.charAt(0) === '-') {
325
+ return {
326
+ field: fields[i][0],
327
+ value: value,
328
+ reason: fields[i][0] + ' may not start with "-" (' + value + ' would be read by git as an option, not as a value)',
329
+ }
330
+ }
331
+ }
332
+ return null
333
+ }
334
+
335
+ /* ── a path that stays inside the repository ──
336
+
337
+ Every path the panel sends is one git itself listed, so it is relative to the
338
+ work-tree root. Three of the four diff reads pass it as a pathspec, which git
339
+ keeps inside the repository on its own. The untracked read cannot: it is
340
+ `git diff --no-index -- /dev/null <path>`, and that command reads whatever the
341
+ path names. Measured here, an absolute path came back with the contents of
342
+ /etc/hostname — a file no reader of a git panel asked for. So anything
343
+ absolute, or stepping up with "..", is refused rather than resolved. */
344
+ function repoRelativePath(path) {
345
+ if (!isStr(path) || path.length === 0) return 'path is required'
346
+ if (path.charAt(0) === '/' || path.charAt(0) === '\\') return 'path must be relative to the repository root'
347
+ if (path.length > 1 && path.charAt(1) === ':') return 'path must be relative to the repository root'
348
+ if (path.indexOf('\u0000') >= 0) return 'path may not contain a NUL byte'
349
+ const parts = path.split(/[\\/]/)
350
+ for (let i = 0; i < parts.length; i += 1) {
351
+ if (parts[i] === '..') return 'path may not step outside the repository'
352
+ }
353
+ return ''
354
+ }
355
+
356
+ function classify(argv) {
357
+ const scan = scanArgv(argv)
358
+ const sub = scan.sub
359
+ const rest = scan.rest
360
+ if (sub === undefined) return { level: 'read', why: '' }
361
+
362
+ if (sub === 'filter-branch' || sub === 'filter-repo') {
363
+ return { level: 'forbidden', why: sub + ' rewrites published history and is never allowed through this tool' }
364
+ }
365
+ if (sub === 'update-ref' && hasAny(rest, ['-d', '--delete'])) {
366
+ return { level: 'forbidden', why: 'update-ref -d deletes refs directly, bypassing every safety net' }
367
+ }
368
+ if (sub === 'reflog' && rest[0] === 'expire') {
369
+ return { level: 'forbidden', why: 'reflog expire destroys the log that makes mistakes recoverable' }
370
+ }
371
+ if (sub === 'gc' && rest.some(function (token) { return isStr(token) && token.indexOf('--prune') === 0 })) {
372
+ return { level: 'forbidden', why: 'gc --prune destroys unreachable objects permanently' }
373
+ }
374
+
375
+ const reasons = []
376
+ if (sub === 'reset' && hasAny(rest, ['--hard'])) reasons.push('reset --hard discards working-tree changes')
377
+ if (sub === 'clean' && (hasAny(rest, ['-f', '--force']) || shortFlag(rest, 'f') !== null)) reasons.push('clean -f deletes untracked files')
378
+ if (sub === 'checkout' && (hasAny(rest, ['-f', '--force']) || shortFlag(rest, 'f') !== null)) reasons.push('checkout --force discards local changes')
379
+ if ((sub === 'checkout' || sub === 'restore') && rest.indexOf('.') >= 0) reasons.push('restoring "." discards every working-tree change')
380
+ if (sub === 'switch' && hasAny(rest, ['-f', '--force', '--discard-changes'])) reasons.push('switch --force discards local changes')
381
+ if (sub === 'branch' && (shortFlag(rest, 'D') !== null || (hasAny(rest, ['--delete']) && hasAny(rest, ['--force'])))) reasons.push('force-deleting a branch discards unmerged commits')
382
+ if (sub === 'stash' && rest[0] === 'clear') reasons.push('stash clear drops every stash entry')
383
+ if (sub === 'stash' && rest[0] === 'drop') reasons.push('stash drop discards a stash entry')
384
+ if (sub === 'reflog' && rest[0] === 'delete') reasons.push('reflog delete removes recovery entries')
385
+ if (sub === 'worktree' && rest[0] === 'remove' && hasAny(rest, ['-f', '--force'])) reasons.push('worktree remove --force deletes a worktree that still has changes')
386
+ if (sub === 'submodule' && rest[0] === 'deinit' && hasAny(rest, ['-f', '--force'])) reasons.push('submodule deinit --force removes a submodule checkout')
387
+ if (sub === 'tag' && hasAny(rest, ['-d', '--delete'])) reasons.push('deleting a tag removes a published reference')
388
+ if (argv.indexOf('--no-verify') >= 0) reasons.push('--no-verify skips the repository hooks')
389
+
390
+ if (sub === 'push') {
391
+ const positional = rest.filter(function (token) { return isStr(token) && token.charAt(0) !== '-' })
392
+ const refspecs = positional.slice(1)
393
+ const forced = hasAny(rest, ['--force', '--mirror']) || shortFlag(rest, 'f') !== null
394
+ const leased = rest.some(function (token) { return isStr(token) && token.indexOf('--force-with-lease') === 0 })
395
+ const deleting = hasAny(rest, ['--delete']) || refspecs.some(function (token) { return token.charAt(0) === ':' })
396
+ if (forced && refspecs.some(refspecTargetsProtected)) {
397
+ return { level: 'forbidden', why: 'force-pushing to a protected branch (main/master) is never allowed' }
398
+ }
399
+ if (forced) reasons.push('push --force overwrites remote history')
400
+ if (leased) reasons.push('push --force-with-lease overwrites remote history')
401
+ if (deleting) reasons.push('push --delete removes a remote reference')
402
+ if (refspecs.some(function (token) { return token.charAt(0) === '+' })) reasons.push('a "+" refspec forces the remote update')
403
+ }
404
+
405
+ if (reasons.length > 0) return { level: 'destructive', why: reasons.join('; ') }
406
+ if (READ_ONLY_SUBCOMMANDS.indexOf(sub) >= 0) return { level: 'read', why: '' }
407
+ return { level: 'write', why: '' }
408
+ }
409
+
410
+ /* ─────────────── renderers ─────────────── */
411
+
412
+ /* The header every renderer prints when a git command came back non-zero. Three
413
+ of them wrote it out by hand. */
414
+ function renderCommandFailure(title, value) {
415
+ const stderr = isStr(value.stderr) ? value.stderr.replace(/\n+$/, '') : ''
416
+ return title + ' failed in ' + String(value.cwd) + '\n' + stderr + '\n[exit code: ' + String(value.exitCode) + ']'
417
+ }
418
+
419
+ function renderPassthrough(value) {
420
+ const lines = ['$ ' + value.command]
421
+ if (value.cwd !== null) lines.push('cwd: ' + value.cwd)
422
+ if (value.blocked === 'invalid-args') {
423
+ lines.push('INVALID ARGUMENTS: ' + value.reason)
424
+ lines.push('Nothing was executed.')
425
+ return lines.join('\n')
426
+ }
427
+ if (value.blocked === 'forbidden') {
428
+ lines.push('BLOCKED by git plugin policy: ' + value.reason)
429
+ return lines.join('\n')
430
+ }
431
+ if (value.blocked === 'confirmation-required') {
432
+ lines.push('CONFIRMATION REQUIRED: ' + value.reason)
433
+ lines.push('Nothing was executed. Re-call with confirm: true only if this destructive operation is really intended.')
434
+ return lines.join('\n')
435
+ }
436
+ /* Guarded rather than assumed: three of the refusal paths above return no
437
+ stdout at all, and a renderer that throws takes the answer with it. */
438
+ const out = isStr(value.stdout) ? value.stdout.replace(/\n+$/, '') : ''
439
+ const err = isStr(value.stderr) ? value.stderr.replace(/\n+$/, '') : ''
440
+ if (out.length > 0) lines.push(out)
441
+ if (err.length > 0) lines.push('[stderr]\n' + err)
442
+ if (out.length === 0 && err.length === 0) lines.push('(no output)')
443
+ lines.push('[exit code: ' + String(value.exitCode) + ']')
444
+ if (value.timedOut === true) lines.push('(timed out)')
445
+ if (value.sandboxDenied === true) lines.push('(denied by the file sandbox)')
446
+ if (value.truncated === true) lines.push('(output truncated' + (value.spillPath !== null ? '; full output at ' + value.spillPath : '') + ')')
447
+ return lines.join('\n')
448
+ }
449
+
450
+ function renderOutcome(value, title) {
451
+ const lines = [title]
452
+ if (value.cwd !== undefined && value.cwd !== null) lines.push('cwd: ' + String(value.cwd))
453
+ if (value.blocked === 'forbidden') { lines.push('BLOCKED by git plugin policy: ' + value.reason); return lines.join('\n') }
454
+ if (value.blocked === 'confirmation-required') {
455
+ lines.push('CONFIRMATION REQUIRED: ' + value.reason)
456
+ lines.push('Nothing was executed. Re-call with confirm: true only if this is really intended.')
457
+ return lines.join('\n')
458
+ }
459
+ const out = isStr(value.stdout) ? value.stdout.replace(/\n+$/, '') : ''
460
+ const err = isStr(value.stderr) ? value.stderr.replace(/\n+$/, '') : ''
461
+ if (value.ok === true) {
462
+ if (out.length > 0) lines.push(out)
463
+ if (err.length > 0) lines.push('[stderr]\n' + err)
464
+ if (out.length === 0 && err.length === 0) lines.push('ok')
465
+ return lines.join('\n')
466
+ }
467
+ if (err.length > 0) lines.push(err)
468
+ if (out.length > 0) lines.push(out)
469
+ lines.push('[exit code: ' + String(value.exitCode) + ']')
470
+ return lines.join('\n')
471
+ }
472
+
473
+ const STATUS_LABELS = {
474
+ M: 'modified', A: 'added', D: 'deleted', R: 'renamed', C: 'copied',
475
+ T: 'typechange', U: 'unmerged', '.': 'unchanged',
476
+ }
477
+
478
+ function statusLabel(code) {
479
+ return STATUS_LABELS[code] === undefined ? code : STATUS_LABELS[code]
480
+ }
481
+
482
+ function parseStatusV2(stdout) {
483
+ const parsed = {
484
+ branch: null, detached: false, upstream: null, ahead: 0, behind: 0,
485
+ staged: [], unstaged: [], untracked: [], unmerged: [],
486
+ }
487
+ const lines = stdout.split('\n')
488
+ for (let i = 0; i < lines.length; i += 1) {
489
+ const line = lines[i]
490
+ if (line.length === 0) continue
491
+ if (line.charAt(0) === '#') {
492
+ const head = line.slice(2)
493
+ const space = head.indexOf(' ')
494
+ const key = space < 0 ? head : head.slice(0, space)
495
+ const rest = space < 0 ? '' : head.slice(space + 1)
496
+ if (key === 'branch.head') {
497
+ if (rest === '(detached)') parsed.detached = true
498
+ else parsed.branch = rest
499
+ } else if (key === 'branch.upstream') {
500
+ parsed.upstream = rest
501
+ } else if (key === 'branch.ab') {
502
+ const parts = rest.split(' ')
503
+ if (parts.length === 2) {
504
+ parsed.ahead = parseInt(parts[0].slice(1), 10) || 0
505
+ parsed.behind = parseInt(parts[1].slice(1), 10) || 0
506
+ }
507
+ }
508
+ continue
509
+ }
510
+ const marker = line.charAt(0)
511
+ if (marker === '?') { parsed.untracked.push(line.slice(2)); continue }
512
+ if (marker === '!') continue
513
+ if (marker === '1' || marker === '2' || marker === 'u') {
514
+ const fields = line.split(' ')
515
+ const xy = fields[1] === undefined ? '..' : fields[1]
516
+ let path = ''
517
+ if (marker === '1') path = fields.slice(8).join(' ')
518
+ else if (marker === '2') path = fields.slice(9).join(' ').split('\t')[0]
519
+ else path = fields.slice(10).join(' ')
520
+ const entry = { path: path, code: xy, label: statusLabel(xy.charAt(0)) + '/' + statusLabel(xy.charAt(1)) }
521
+ if (marker === 'u') { parsed.unmerged.push(entry); continue }
522
+ if (xy.charAt(0) !== '.') parsed.staged.push(entry)
523
+ if (xy.charAt(1) !== '.') parsed.unstaged.push(entry)
524
+ }
525
+ }
526
+ return parsed
527
+ }
528
+
529
+ function renderStatus(value) {
530
+ if (value.ok !== true) return renderCommandFailure('git status', value)
531
+ const lines = []
532
+ lines.push('repo: ' + String(value.cwd))
533
+ const head = value.detached === true ? '(detached HEAD)' : String(value.branch)
534
+ const track = value.upstream === null ? '' : ' -> ' + value.upstream + ' ahead ' + String(value.ahead) + ', behind ' + String(value.behind)
535
+ lines.push('branch: ' + head + track)
536
+ if (value.unmerged.length > 0) {
537
+ lines.push('conflicts: ' + String(value.unmerged.length))
538
+ for (let i = 0; i < value.unmerged.length; i += 1) lines.push(' ' + value.unmerged[i].code + ' ' + value.unmerged[i].path)
539
+ }
540
+ lines.push('staged: ' + String(value.staged.length))
541
+ for (let i = 0; i < value.staged.length; i += 1) lines.push(' ' + value.staged[i].code + ' ' + value.staged[i].path)
542
+ lines.push('unstaged: ' + String(value.unstaged.length))
543
+ for (let i = 0; i < value.unstaged.length; i += 1) lines.push(' ' + value.unstaged[i].code + ' ' + value.unstaged[i].path)
544
+ lines.push('untracked: ' + String(value.untracked.length))
545
+ for (let i = 0; i < value.untracked.length; i += 1) lines.push(' ?? ' + value.untracked[i])
546
+ if (value.clean === true) lines.push('working tree clean')
547
+ return lines.join('\n')
548
+ }
549
+
550
+ function renderLog(value) {
551
+ if (value.ok !== true) return renderCommandFailure('git log', value)
552
+ if (value.commits.length === 0) return 'no commits matched in ' + String(value.cwd)
553
+ const lines = []
554
+ for (let i = 0; i < value.commits.length; i += 1) {
555
+ const entry = value.commits[i]
556
+ const refs = entry.refs.length > 0 ? ' (' + entry.refs + ')' : ''
557
+ lines.push(entry.short + ' ' + entry.date + ' ' + entry.author + ' ' + entry.subject + refs)
558
+ }
559
+ return lines.join('\n')
560
+ }
561
+
562
+ function renderDiff(value) {
563
+ if (value.ok !== true) return renderCommandFailure('git diff', value)
564
+ const lines = ['diff mode: ' + value.mode, 'cwd: ' + String(value.cwd), 'changed files: ' + String(value.paths.length)]
565
+ for (let i = 0; i < value.paths.length; i += 1) lines.push(' ' + value.paths[i])
566
+ if (value.note !== null) lines.push('note: ' + value.note)
567
+ if (value.cardAvailable === true) lines.push('(a native diff card is attached to this call)')
568
+ if (value.truncated === true) lines.push('(some file content was truncated in the card)')
569
+ if (value.patch !== null) { lines.push(''); lines.push(value.patch.replace(/\n+$/, '')) }
570
+ return lines.join('\n')
571
+ }
572
+
573
+ function renderBranches(value) {
574
+ if (value.ok !== true) return renderOutcome(value, 'git branch')
575
+ const lines = ['current: ' + (value.current === null ? '(detached or unknown)' : value.current)]
576
+ for (let i = 0; i < value.branches.length; i += 1) {
577
+ const entry = value.branches[i]
578
+ const track = entry.upstream.length > 0 ? ' -> ' + entry.upstream : ''
579
+ lines.push((entry.current ? '* ' : ' ') + entry.name + track + (entry.subject.length > 0 ? ' ' + entry.subject : ''))
580
+ }
581
+ return lines.join('\n')
582
+ }
583
+
584
+ function renderStashes(value) {
585
+ if (value.ok !== true) return renderOutcome(value, 'git stash')
586
+ if (value.stashes.length === 0) return 'no stash entries'
587
+ const lines = []
588
+ for (let i = 0; i < value.stashes.length; i += 1) {
589
+ const entry = value.stashes[i]
590
+ lines.push(entry.ref + ' ' + entry.date + ' ' + entry.subject)
591
+ }
592
+ return lines.join('\n')
593
+ }
594
+
595
+ /* ─────────────── diff helpers ─────────────── */
596
+
597
+ function parseNulList(stdout) {
598
+ const parts = stdout.split('\u0000')
599
+ const out = []
600
+ for (let i = 0; i < parts.length; i += 1) if (parts[i].length > 0) out.push(parts[i])
601
+ return out
602
+ }
603
+
604
+ function capText(text) {
605
+ const lines = text.split('\n')
606
+ if (lines.length > 4000) return { text: lines.slice(0, 4000).join('\n'), cut: true }
607
+ if (text.length > 300000) return { text: text.slice(0, 300000), cut: true }
608
+ return { text: text, cut: false }
609
+ }
610
+
611
+ /* A path is data, and `cat <path>` did not treat it as data: a file called `-n`
612
+ is an option to cat, so the card showed it as empty rather than as itself.
613
+ (`:./path` would be the other half of this — except that git already resolves
614
+ `:<path>` correctly even when the path contains a colon, and `./` would break
615
+ the case where the repository path is a subdirectory, so the spec is left as
616
+ git's plain `:<path>` form.) */
617
+ async function readBlob(args, spec, exec) {
618
+ const result = await git(args, ['show', spec], exec, { maxBytes: 400000 })
619
+ if (result.exitCode !== 0) return null
620
+ return result.stdout
621
+ }
622
+
623
+ async function readWorktreeFile(args, path, exec) {
624
+ const result = await invoke('cat -- ' + shq(path), args, exec, { maxBytes: 400000 })
625
+ if (result.exitCode !== 0) return null
626
+ return result.stdout
627
+ }
628
+
629
+ const CARD_TOTAL_LIMIT = 500000
630
+
631
+ /* ─────────────── tool registration ─────────────── */
632
+
633
+ function define(name, definition) {
634
+ definition.name = name
635
+ ctx.effect(function () {
636
+ return harness.registerTool(ctx, harness.defineTool(definition))
637
+ }, 'dsh-git-idea tool ' + name)
638
+ }
639
+
640
+ define('git', {
641
+ description: 'Run any git command as a token array: the complete escape hatch behind the structured git_* tools. There is no argument allowlist, so every git subcommand works (clone, init, worktree, submodule, bisect, tag, merge, rebase, cherry-pick, revert, blame, gc, ...). Destructive commands are refused unless confirm: true is also passed. Always check the reported exit code.',
642
+ parameters: {
643
+ args: {
644
+ type: 'array',
645
+ items: { type: 'string' },
646
+ required: true,
647
+ description: 'git arguments as separate tokens, WITHOUT the leading "git". Example: ["log", "--oneline", "-n", "5"].',
648
+ },
649
+ repo: { type: 'string', description: 'Repository directory. Defaults to the session working directory.' },
650
+ stdin: { type: 'string', description: 'Text piped to git standard input.' },
651
+ timeoutMs: { type: 'number', description: 'Timeout in milliseconds. Default 120000.' },
652
+ confirm: { type: 'boolean', description: 'Must be true to allow a destructive command. Without it the command is refused and nothing runs.' },
653
+ },
654
+ output: {
655
+ schema: { type: 'json' },
656
+ render: function (_args, value) { return [{ type: 'text', text: renderPassthrough(value) }] },
657
+ },
658
+ isConcurrencySafe: function (args) {
659
+ return classify(Array.isArray(args.args) ? args.args.filter(isStr) : []).level === 'read'
660
+ },
661
+ execute: async function (args, exec) {
662
+ const argv = Array.isArray(args.args) ? args.args.filter(isStr) : []
663
+ const cwd = here(args, exec)
664
+ if (argv.length === 0) {
665
+ return { ok: false, blocked: 'invalid-args', reason: 'args must hold at least one git token, e.g. ["status"]', command: 'git', cwd: cwd }
666
+ }
667
+ const verdict = classify(argv)
668
+ if (verdict.level === 'forbidden') {
669
+ return { ok: false, blocked: 'forbidden', reason: verdict.why, command: 'git ' + argv.join(' '), cwd: cwd }
670
+ }
671
+ if (verdict.level === 'destructive' && args.confirm !== true) {
672
+ return { ok: false, blocked: 'confirmation-required', reason: verdict.why, command: 'git ' + argv.join(' '), cwd: cwd }
673
+ }
674
+ const options = {}
675
+ if (isStr(args.stdin)) options.stdin = args.stdin
676
+ if (typeof args.timeoutMs === 'number') options.timeoutMs = args.timeoutMs
677
+ return await git(args, argv, exec, options)
678
+ },
679
+ })
680
+
681
+ define('git_status', {
682
+ description: 'Structured repository status: current branch, detached state, upstream, ahead/behind counts, and the staged, unstaged, untracked and unmerged file lists. Reads git status --porcelain=v2, so it is stable across git versions.',
683
+ parameters: {
684
+ repo: { type: 'string', description: 'Repository directory. Defaults to the session working directory.' },
685
+ },
686
+ output: {
687
+ schema: { type: 'json' },
688
+ render: function (_args, value) { return [{ type: 'text', text: renderStatus(value) }] },
689
+ },
690
+ isConcurrencySafe: function () { return true },
691
+ execute: async function (args, exec) {
692
+ /* A read must not take .git/index.lock: `git status` would happily refresh
693
+ the index cache, and a tool call that overlaps anyone else's `git add`
694
+ makes THEIR command fail with "Unable to create index.lock". */
695
+ const result = await git(args, ['--no-optional-locks', '-c', 'core.quotePath=false', 'status', '--porcelain=v2', '--branch', '--untracked-files=all'], exec, {})
696
+ if (result.exitCode !== 0) {
697
+ return { ok: false, cwd: result.cwd, exitCode: result.exitCode, stderr: result.stderr, error: 'not-a-repository' }
698
+ }
699
+ const parsed = parseStatusV2(result.stdout)
700
+ parsed.ok = true
701
+ parsed.cwd = result.cwd
702
+ parsed.exitCode = result.exitCode
703
+ parsed.clean = parsed.staged.length === 0 && parsed.unstaged.length === 0 && parsed.untracked.length === 0 && parsed.unmerged.length === 0
704
+ parsed.stderr = result.stderr
705
+ return parsed
706
+ },
707
+ })
708
+
709
+ define('git_log', {
710
+ description: 'Structured commit history: hash, short hash, author, ISO date, subject line and the refs each commit carries. Supports a revision range, a path filter and a result cap.',
711
+ parameters: {
712
+ repo: { type: 'string', description: 'Repository directory. Defaults to the session working directory.' },
713
+ maxCount: { type: 'number', description: 'Maximum commits to return. Default 20, hard cap 200.' },
714
+ ref: { type: 'string', description: 'Revision or range to walk, e.g. "HEAD", "main..feature", "v1.0.0". Default HEAD.' },
715
+ path: { type: 'string', description: 'Limit history to one path.' },
716
+ },
717
+ output: {
718
+ schema: { type: 'json' },
719
+ render: function (_args, value) { return [{ type: 'text', text: renderLog(value) }] },
720
+ },
721
+ isConcurrencySafe: function () { return true },
722
+ execute: async function (args, exec) {
723
+ const requested = typeof args.maxCount === 'number' && args.maxCount > 0 ? Math.floor(args.maxCount) : 20
724
+ const maxCount = requested > 200 ? 200 : requested
725
+ const ref = isStr(args.ref) ? args.ref.trim() : ''
726
+ const path = isStr(args.path) ? args.path.trim() : ''
727
+ /* A revision is positional, so a "-"-leading one is an option to git. A path
728
+ is not guarded here: it travels after `--`, where git already reads it as a
729
+ path, and a file called `-notes.txt` is a legitimate name. */
730
+ const bad = optionLike([['ref', ref]])
731
+ if (bad !== null) {
732
+ return { ok: false, cwd: here(args, exec), exitCode: null, stderr: bad.reason, error: 'option-like-value' }
733
+ }
734
+ const argv = ['-c', 'core.quotePath=false', 'log', '--max-count=' + String(maxCount), '--pretty=format:%H%x1f%h%x1f%an%x1f%aI%x1f%s%x1f%D%x1e']
735
+ if (ref.length > 0) argv.push(ref)
736
+ if (path.length > 0) { argv.push('--'); argv.push(path) }
737
+ const result = await git(args, argv, exec, {})
738
+ if (result.exitCode !== 0) {
739
+ return { ok: false, cwd: result.cwd, exitCode: result.exitCode, stderr: result.stderr, error: 'log-failed' }
740
+ }
741
+ const commits = []
742
+ const records = result.stdout.split('\u001e')
743
+ for (let i = 0; i < records.length; i += 1) {
744
+ const record = records[i].replace(/^\n+/, '')
745
+ if (record.length === 0) continue
746
+ const fields = record.split('\u001f')
747
+ const rawDate = field(fields, 3)
748
+ commits.push({
749
+ hash: field(fields, 0),
750
+ short: field(fields, 1),
751
+ author: field(fields, 2),
752
+ date: rawDate.length >= 16 ? rawDate.slice(0, 16).replace('T', ' ') : rawDate,
753
+ subject: field(fields, 4),
754
+ refs: field(fields, 5),
755
+ })
756
+ }
757
+ return { ok: true, cwd: result.cwd, exitCode: result.exitCode, count: commits.length, commits: commits, stderr: result.stderr }
758
+ },
759
+ })
760
+
761
+ define('git_diff', {
762
+ description: 'Diff between two repository states, returning the changed file list plus full before/after content so the UI renders a native diff card. modes: "worktree" (index vs working tree), "staged" (HEAD vs index), "commit" (ref against its first parent), "range" (ref..to). Narrow with paths for a large change set: above maxFiles the card is skipped and the raw unified patch is returned instead.',
763
+ parameters: {
764
+ repo: { type: 'string', description: 'Repository directory. Defaults to the session working directory.' },
765
+ mode: { type: 'string', required: true, enum: ['worktree', 'staged', 'commit', 'range'], description: 'Which two states to compare.' },
766
+ ref: { type: 'string', description: 'Base revision: for "commit" the commit to show (default HEAD); for "range" the range start.' },
767
+ to: { type: 'string', description: 'Range end. Required for mode "range".' },
768
+ paths: { type: 'array', items: { type: 'string' }, description: 'Limit the diff to these paths.' },
769
+ maxFiles: { type: 'number', description: 'Maximum files to build the diff card for. Default 12, cap 50.' },
770
+ },
771
+ output: {
772
+ schema: { type: 'json' },
773
+ render: function (_args, value) { return [{ type: 'text', text: renderDiff(value) }] },
774
+ presentationMeta: function (_args, value) {
775
+ return { mode: value.mode, cardAvailable: value.cardAvailable === true, diffs: value.files }
776
+ },
777
+ },
778
+ isConcurrencySafe: function () { return true },
779
+ presentResult: function (_args, result) {
780
+ if (result == null || result.isError === true) return undefined
781
+ const meta = result.meta
782
+ if (meta == null || meta.cardAvailable !== true) return undefined
783
+ const diffs = Array.isArray(meta.diffs) ? meta.diffs : []
784
+ if (diffs.length === 0) return undefined
785
+ return { card: 'diff', title: 'git diff (' + String(meta.mode) + ')', diffs: diffs }
786
+ },
787
+ execute: async function (args, exec) {
788
+ const mode = isStr(args.mode) ? args.mode : 'worktree'
789
+ const bad = optionLike([['ref', isStr(args.ref) ? args.ref.trim() : ''], ['to', isStr(args.to) ? args.to.trim() : '']])
790
+ if (bad !== null) {
791
+ return { ok: false, cwd: here(args, exec), exitCode: null, stderr: bad.reason, error: 'option-like-value' }
792
+ }
793
+ const requested = typeof args.maxFiles === 'number' && args.maxFiles > 0 ? Math.floor(args.maxFiles) : 12
794
+ const maxFiles = requested > 50 ? 50 : requested
795
+ const paths = Array.isArray(args.paths) ? args.paths.filter(isStr) : []
796
+ const ref = isStr(args.ref) && args.ref.trim().length > 0 ? args.ref.trim() : 'HEAD'
797
+ const to = isStr(args.to) && args.to.trim().length > 0 ? args.to.trim() : null
798
+ const suffix = paths.length > 0 ? ['--'].concat(paths) : []
799
+
800
+ let listArgv
801
+ let patchArgv
802
+ let oldSpec
803
+ let newSpec
804
+ let fromWorktree = false
805
+
806
+ if (mode === 'worktree') {
807
+ listArgv = ['-c', 'core.quotePath=false', 'diff', '--name-only', '--no-renames', '-z'].concat(suffix)
808
+ patchArgv = ['-c', 'core.quotePath=false', 'diff', '--no-color', '-U3'].concat(suffix)
809
+ oldSpec = function (path) { return ':' + path }
810
+ fromWorktree = true
811
+ } else if (mode === 'staged') {
812
+ listArgv = ['-c', 'core.quotePath=false', 'diff', '--cached', '--name-only', '--no-renames', '-z'].concat(suffix)
813
+ patchArgv = ['-c', 'core.quotePath=false', 'diff', '--cached', '--no-color', '-U3'].concat(suffix)
814
+ oldSpec = function (path) { return 'HEAD:' + path }
815
+ newSpec = function (path) { return ':' + path }
816
+ } else if (mode === 'commit') {
817
+ listArgv = ['-c', 'core.quotePath=false', 'show', '--name-only', '--no-renames', '-z', '--format=', ref].concat(suffix)
818
+ patchArgv = ['-c', 'core.quotePath=false', 'show', '--no-color', '-U3', '--format=', ref].concat(suffix)
819
+ oldSpec = function (path) { return ref + '^:' + path }
820
+ newSpec = function (path) { return ref + ':' + path }
821
+ } else if (mode === 'range') {
822
+ if (to === null) return { ok: false, cwd: here(args, exec), error: 'mode "range" needs a "to" revision' }
823
+ listArgv = ['-c', 'core.quotePath=false', 'diff', '--name-only', '--no-renames', '-z', ref + '..' + to].concat(suffix)
824
+ patchArgv = ['-c', 'core.quotePath=false', 'diff', '--no-color', '-U3', ref + '..' + to].concat(suffix)
825
+ oldSpec = function (path) { return ref + ':' + path }
826
+ newSpec = function (path) { return to + ':' + path }
827
+ } else {
828
+ return { ok: false, cwd: here(args, exec), error: 'unknown mode ' + mode }
829
+ }
830
+
831
+ const listed = await git(args, listArgv, exec, {})
832
+ if (listed.exitCode !== 0) {
833
+ return { ok: false, cwd: listed.cwd, exitCode: listed.exitCode, stderr: listed.stderr, error: 'diff-failed' }
834
+ }
835
+ const names = parseNulList(listed.stdout)
836
+ const result = {
837
+ ok: true, cwd: listed.cwd, exitCode: listed.exitCode, mode: mode,
838
+ paths: names, files: [], cardAvailable: false, truncated: false, patch: null, note: null, stderr: listed.stderr,
839
+ }
840
+ if (names.length === 0) { result.note = 'no differences in this mode'; return result }
841
+
842
+ if (names.length > maxFiles) {
843
+ const patch = await git(args, patchArgv, exec, { maxBytes: 400000 })
844
+ result.patch = patch.stdout
845
+ result.note = String(names.length) + ' files changed, more than maxFiles=' + String(maxFiles) + '; pass paths to narrow the diff and get the diff card'
846
+ return result
847
+ }
848
+
849
+ const files = []
850
+ let total = 0
851
+ let cut = false
852
+ let overLimit = false
853
+ for (let i = 0; i < names.length; i += 1) {
854
+ const path = names[i]
855
+ const rawOld = await readBlob(args, oldSpec(path), exec)
856
+ let rawNew = null
857
+ if (fromWorktree) rawNew = await readWorktreeFile(args, path, exec)
858
+ else rawNew = await readBlob(args, newSpec(path), exec)
859
+ const oldCapped = rawOld === null ? null : capText(rawOld)
860
+ const newCapped = capText(rawNew === null ? '' : rawNew)
861
+ if ((oldCapped !== null && oldCapped.cut) || newCapped.cut) cut = true
862
+ total += (oldCapped === null ? 0 : oldCapped.text.length) + newCapped.text.length
863
+ files.push({ path: path, oldText: oldCapped === null ? null : oldCapped.text, newText: newCapped.text })
864
+ if (total > CARD_TOTAL_LIMIT) { overLimit = true; break }
865
+ }
866
+ result.truncated = cut
867
+ if (overLimit) {
868
+ const patch = await git(args, patchArgv, exec, { maxBytes: 400000 })
869
+ result.patch = patch.stdout
870
+ result.note = 'the combined before/after content is too large for the diff card; the raw patch is returned instead'
871
+ return result
872
+ }
873
+ result.files = files
874
+ result.cardAvailable = true
875
+ return result
876
+ },
877
+ })
878
+
879
+ define('git_commit', {
880
+ description: 'Stage and commit in one step. By default it commits exactly what is already staged; pass paths to stage only those paths first, or all: true to stage every change. Reports the new commit hash.',
881
+ parameters: {
882
+ repo: { type: 'string', description: 'Repository directory. Defaults to the session working directory.' },
883
+ message: { type: 'string', required: true, description: 'Commit message. Passed as a single argument, never through a shell.' },
884
+ paths: { type: 'array', items: { type: 'string' }, description: 'Stage only these paths before committing.' },
885
+ all: { type: 'boolean', description: 'Stage every change (git add -A) before committing.' },
886
+ amend: { type: 'boolean', description: 'Amend the previous commit instead of creating a new one.' },
887
+ signoff: { type: 'boolean', description: 'Add a Signed-off-by trailer.' },
888
+ },
889
+ output: {
890
+ schema: { type: 'json' },
891
+ render: function (_args, value) {
892
+ if (value.ok === true) return [{ type: 'text', text: 'committed ' + String(value.hash) + (value.amended === true ? ' (amended)' : '') + '\n' + String(value.stdout).replace(/\n+$/, '') }]
893
+ return [{ type: 'text', text: renderOutcome(value, 'git commit') }]
894
+ },
895
+ },
896
+ isConcurrencySafe: function () { return false },
897
+ execute: async function (args, exec) {
898
+ const paths = Array.isArray(args.paths) ? args.paths.filter(isStr) : []
899
+ if (args.all === true) {
900
+ const staged = await git(args, ['add', '-A'], exec, {})
901
+ if (staged.exitCode !== 0) return { ok: false, cwd: staged.cwd, exitCode: staged.exitCode, stdout: staged.stdout, stderr: staged.stderr, error: 'stage-failed' }
902
+ } else if (paths.length > 0) {
903
+ const staged = await git(args, ['add', '--'].concat(paths), exec, {})
904
+ if (staged.exitCode !== 0) return { ok: false, cwd: staged.cwd, exitCode: staged.exitCode, stdout: staged.stdout, stderr: staged.stderr, error: 'stage-failed' }
905
+ }
906
+ const argv = ['commit', '-m', args.message]
907
+ if (args.amend === true) argv.push('--amend')
908
+ if (args.signoff === true) argv.push('--signoff')
909
+ const committed = await git(args, argv, exec, {})
910
+ if (committed.exitCode !== 0) {
911
+ return { ok: false, cwd: committed.cwd, exitCode: committed.exitCode, stdout: committed.stdout, stderr: committed.stderr, error: 'commit-failed' }
912
+ }
913
+ const rev = await git(args, ['rev-parse', '--short', 'HEAD'], exec, {})
914
+ return {
915
+ ok: true, cwd: committed.cwd, exitCode: committed.exitCode, amended: args.amend === true,
916
+ hash: rev.exitCode === 0 ? rev.stdout.trim() : null,
917
+ message: args.message, stdout: committed.stdout, stderr: committed.stderr,
918
+ }
919
+ },
920
+ })
921
+
922
+ define('git_branch', {
923
+ description: 'List, create, switch, delete or rename branches. "list" reports local branches with their upstream, head commit and subject; force-deleting needs confirm: true.',
924
+ parameters: {
925
+ repo: { type: 'string', description: 'Repository directory. Defaults to the session working directory.' },
926
+ action: { type: 'string', required: true, enum: ['list', 'create', 'switch', 'delete', 'rename'], description: 'Operation to perform.' },
927
+ name: { type: 'string', description: 'Branch name. Required for every action except "list". For "rename" it is the NEW name of the current branch.' },
928
+ startPoint: { type: 'string', description: 'For "create", the start point; for "switch", creates the branch there first.' },
929
+ all: { type: 'boolean', description: 'For "list", include remote-tracking branches.' },
930
+ force: { type: 'boolean', description: 'For "delete", delete even when unmerged (git branch -D). Needs confirm: true.' },
931
+ confirm: { type: 'boolean', description: 'Must be true when force is used.' },
932
+ },
933
+ output: {
934
+ schema: { type: 'json' },
935
+ render: function (_args, value) {
936
+ if (value.action === 'list') return [{ type: 'text', text: renderBranches(value) }]
937
+ return [{ type: 'text', text: renderOutcome(value, 'git branch ' + String(value.action)) }]
938
+ },
939
+ },
940
+ isConcurrencySafe: function () { return false },
941
+ execute: async function (args, exec) {
942
+ const action = args.action
943
+ const name = isStr(args.name) && args.name.trim().length > 0 ? args.name.trim() : null
944
+ if (action !== 'list' && name === null) {
945
+ return { ok: false, action: action, cwd: here(args, exec), error: 'name is required for action ' + action }
946
+ }
947
+ const startPoint = isStr(args.startPoint) ? args.startPoint.trim() : ''
948
+ const bad = optionLike([['name', name === null ? '' : name], ['startPoint', startPoint]])
949
+ if (bad !== null) {
950
+ return { ok: false, action: action, cwd: here(args, exec), exitCode: null, stderr: bad.reason, error: 'option-like-value' }
951
+ }
952
+ if (action === 'delete' && args.force === true && args.confirm !== true) {
953
+ return { ok: false, action: action, cwd: here(args, exec), blocked: 'confirmation-required', reason: 'force-deleting a branch discards commits that are not merged anywhere else' }
954
+ }
955
+
956
+ if (action === 'list') {
957
+ const argv = ['-c', 'core.quotePath=false', 'branch', '--format=%(refname:short)%1f%(HEAD)%1f%(upstream:short)%1f%(objectname:short)%1f%(contents:subject)']
958
+ if (args.all === true) argv.push('-a')
959
+ const listed = await git(args, argv, exec, {})
960
+ if (listed.exitCode !== 0) {
961
+ return { ok: false, action: 'list', cwd: listed.cwd, exitCode: listed.exitCode, stdout: listed.stdout, stderr: listed.stderr, error: 'branch-list-failed' }
962
+ }
963
+ const branches = []
964
+ let current = null
965
+ const rows = listed.stdout.split('\n')
966
+ for (let i = 0; i < rows.length; i += 1) {
967
+ const row = rows[i]
968
+ if (row.length === 0) continue
969
+ const fields = row.split('\u001f')
970
+ const isCurrent = fields[1] === '*'
971
+ if (isCurrent) current = fields[0] === undefined ? null : fields[0]
972
+ branches.push({
973
+ name: field(fields, 0),
974
+ current: isCurrent,
975
+ upstream: field(fields, 2),
976
+ head: field(fields, 3),
977
+ subject: field(fields, 4),
978
+ })
979
+ }
980
+ return { ok: true, action: 'list', cwd: listed.cwd, exitCode: listed.exitCode, current: current, branches: branches, stdout: listed.stdout, stderr: listed.stderr }
981
+ }
982
+
983
+ let argv
984
+ if (action === 'create') {
985
+ argv = ['branch', name]
986
+ if (startPoint.length > 0) argv.push(startPoint)
987
+ } else if (action === 'switch') {
988
+ if (startPoint.length > 0) argv = ['switch', '-c', name, startPoint]
989
+ else argv = ['switch', name]
990
+ } else if (action === 'delete') {
991
+ argv = ['branch', args.force === true ? '-D' : '-d', name]
992
+ } else {
993
+ argv = ['branch', '-m', name]
994
+ }
995
+ const done = await git(args, argv, exec, {})
996
+ done.action = action
997
+ return done
998
+ },
999
+ })
1000
+
1001
+ define('git_stash', {
1002
+ description: 'Manage the stash: list entries, push the current changes onto it, pop or apply an entry, show one, or drop/clear entries. Dropping and clearing need confirm: true.',
1003
+ parameters: {
1004
+ repo: { type: 'string', description: 'Repository directory. Defaults to the session working directory.' },
1005
+ action: { type: 'string', required: true, enum: ['list', 'push', 'pop', 'apply', 'show', 'drop', 'clear'], description: 'Operation to perform.' },
1006
+ message: { type: 'string', description: 'For "push", the stash message.' },
1007
+ index: { type: 'number', description: 'For pop/apply/show/drop, which stash entry (default 0, the most recent).' },
1008
+ paths: { type: 'array', items: { type: 'string' }, description: 'For "push", stash only these paths.' },
1009
+ confirm: { type: 'boolean', description: 'Must be true for "drop" and "clear".' },
1010
+ },
1011
+ output: {
1012
+ schema: { type: 'json' },
1013
+ render: function (_args, value) {
1014
+ if (value.action === 'list') return [{ type: 'text', text: renderStashes(value) }]
1015
+ return [{ type: 'text', text: renderOutcome(value, 'git stash ' + String(value.action)) }]
1016
+ },
1017
+ },
1018
+ isConcurrencySafe: function () { return false },
1019
+ execute: async function (args, exec) {
1020
+ const action = args.action
1021
+ const rawIndex = typeof args.index === 'number' && args.index >= 0 ? Math.floor(args.index) : 0
1022
+ const selector = 'stash@{' + String(rawIndex) + '}'
1023
+
1024
+ if (action === 'clear') {
1025
+ if (args.confirm !== true) return { ok: false, action: action, cwd: here(args, exec), blocked: 'confirmation-required', reason: 'stash clear drops every stash entry permanently' }
1026
+ const done = await git(args, ['stash', 'clear'], exec, {})
1027
+ done.action = action
1028
+ return done
1029
+ }
1030
+ if (action === 'drop') {
1031
+ if (args.confirm !== true) return { ok: false, action: action, cwd: here(args, exec), blocked: 'confirmation-required', reason: 'stash drop discards a stash entry permanently' }
1032
+ const done = await git(args, ['stash', 'drop', selector], exec, {})
1033
+ done.action = action
1034
+ return done
1035
+ }
1036
+ if (action === 'list') {
1037
+ const listed = await git(args, ['stash', 'list', '--format=%gd%x1f%gs%x1f%aI'], exec, {})
1038
+ if (listed.exitCode !== 0) {
1039
+ return { ok: false, action: 'list', cwd: listed.cwd, exitCode: listed.exitCode, stdout: listed.stdout, stderr: listed.stderr, error: 'stash-list-failed', stashes: [] }
1040
+ }
1041
+ const stashes = []
1042
+ const rows = listed.stdout.split('\n')
1043
+ for (let i = 0; i < rows.length; i += 1) {
1044
+ if (rows[i].length === 0) continue
1045
+ const fields = rows[i].split('\u001f')
1046
+ const stamp = field(fields, 2)
1047
+ stashes.push({
1048
+ ref: field(fields, 0),
1049
+ subject: field(fields, 1),
1050
+ date: stamp.length >= 16 ? stamp.slice(0, 16).replace('T', ' ') : stamp,
1051
+ })
1052
+ }
1053
+ return { ok: true, action: 'list', cwd: listed.cwd, exitCode: listed.exitCode, stashes: stashes, stdout: listed.stdout, stderr: listed.stderr }
1054
+ }
1055
+
1056
+ let argv
1057
+ if (action === 'push') {
1058
+ argv = ['stash', 'push']
1059
+ if (isStr(args.message) && args.message.length > 0) { argv.push('-m'); argv.push(args.message) }
1060
+ const paths = Array.isArray(args.paths) ? args.paths.filter(isStr) : []
1061
+ if (paths.length > 0) argv = argv.concat(['--']).concat(paths)
1062
+ } else {
1063
+ argv = ['stash', action, selector]
1064
+ }
1065
+ const done = await git(args, argv, exec, {})
1066
+ done.action = action
1067
+ return done
1068
+ },
1069
+ })
1070
+
1071
+ define('git_sync', {
1072
+ description: 'Work with remotes: fetch, pull, push, list remotes, and add/remove/retarget one. A protected branch (main/master) can never be force-pushed, and any force push needs confirm: true.',
1073
+ parameters: {
1074
+ repo: { type: 'string', description: 'Repository directory. Defaults to the session working directory.' },
1075
+ action: { type: 'string', required: true, enum: ['fetch', 'pull', 'push', 'remote-list', 'remote-add', 'remote-remove', 'set-url'], description: 'Operation to perform.' },
1076
+ remote: { type: 'string', description: 'Remote name, e.g. "origin".' },
1077
+ branch: { type: 'string', description: 'Branch to pull or push.' },
1078
+ url: { type: 'string', description: 'Remote URL for remote-add and set-url.' },
1079
+ setUpstream: { type: 'boolean', description: 'For "push", set the upstream tracking branch (git push -u).' },
1080
+ prune: { type: 'boolean', description: 'For "fetch", drop remote-tracking refs that no longer exist (--prune).' },
1081
+ ff: { type: 'string', enum: ['auto', 'only', 'rebase'], description: 'For "pull": "only" passes --ff-only, "rebase" passes --rebase.' },
1082
+ force: { type: 'string', enum: ['none', 'lease', 'force'], description: 'For "push": "lease" passes --force-with-lease, "force" passes --force and needs confirm: true.' },
1083
+ confirm: { type: 'boolean', description: 'Must be true for a force push and for remote-remove.' },
1084
+ },
1085
+ output: {
1086
+ schema: { type: 'json' },
1087
+ render: function (_args, value) { return [{ type: 'text', text: renderOutcome(value, 'git ' + String(value.action)) }] },
1088
+ },
1089
+ isConcurrencySafe: function () { return false },
1090
+ execute: async function (args, exec) {
1091
+ const action = args.action
1092
+ const remote = isStr(args.remote) && args.remote.trim().length > 0 ? args.remote.trim() : null
1093
+ const branch = isStr(args.branch) && args.branch.trim().length > 0 ? args.branch.trim() : null
1094
+ const url = isStr(args.url) && args.url.trim().length > 0 ? args.url.trim() : null
1095
+ const cwd = here(args, exec)
1096
+ const bad = optionLike([['remote', remote], ['branch', branch], ['url', url]])
1097
+ if (bad !== null) {
1098
+ return { ok: false, action: action, cwd: cwd, exitCode: null, stderr: bad.reason, error: 'option-like-value' }
1099
+ }
1100
+
1101
+ if (action === 'push' && args.force === 'force') {
1102
+ /* The branch that is not named is the branch you are on, and "a protected
1103
+ branch can never be force-pushed" has to mean that branch too: without
1104
+ this, `force: 'force'` with no branch force-pushed whatever was checked
1105
+ out after a bare `confirm`. Resolved here, once, on the one path that
1106
+ needs it. */
1107
+ let target = branch
1108
+ if (target === null) {
1109
+ const head = await git(args, ['symbolic-ref', '--quiet', '--short', 'HEAD'], exec, {})
1110
+ target = head.exitCode === 0 ? head.stdout.trim() : null
1111
+ }
1112
+ if (target !== null && isProtectedBranch(target)) {
1113
+ return { ok: false, action: action, cwd: cwd, blocked: 'forbidden', reason: 'force-pushing to a protected branch (main/master) is never allowed' }
1114
+ }
1115
+ }
1116
+ if (action === 'push' && (args.force === 'force' || args.force === 'lease') && args.confirm !== true) {
1117
+ return { ok: false, action: action, cwd: cwd, blocked: 'confirmation-required', reason: 'a force push overwrites remote history' }
1118
+ }
1119
+ if (action === 'remote-remove' && args.confirm !== true) {
1120
+ return { ok: false, action: action, cwd: cwd, blocked: 'confirmation-required', reason: 'remote remove detaches every local branch that tracks it' }
1121
+ }
1122
+ if ((action === 'remote-add' || action === 'set-url') && (remote === null || url === null)) {
1123
+ return { ok: false, action: action, cwd: cwd, error: action + ' needs both remote and url' }
1124
+ }
1125
+ if (action === 'remote-remove' && remote === null) {
1126
+ return { ok: false, action: action, cwd: cwd, error: action + ' needs remote' }
1127
+ }
1128
+
1129
+ let argv
1130
+ if (action === 'fetch') {
1131
+ argv = ['fetch']
1132
+ if (remote !== null) argv.push(remote)
1133
+ if (args.prune === true) argv.push('--prune')
1134
+ } else if (action === 'pull') {
1135
+ argv = ['pull']
1136
+ if (remote !== null) argv.push(remote)
1137
+ if (branch !== null) argv.push(branch)
1138
+ if (args.ff === 'only') argv.push('--ff-only')
1139
+ else if (args.ff === 'rebase') argv.push('--rebase')
1140
+ } else if (action === 'push') {
1141
+ argv = ['push']
1142
+ if (args.setUpstream === true) argv.push('-u')
1143
+ if (args.force === 'lease') argv.push('--force-with-lease')
1144
+ else if (args.force === 'force') argv.push('--force')
1145
+ if (remote !== null) argv.push(remote)
1146
+ if (branch !== null) argv.push(branch)
1147
+ } else if (action === 'remote-list') {
1148
+ argv = ['remote', '-v']
1149
+ } else if (action === 'remote-add') {
1150
+ argv = ['remote', 'add', remote, url]
1151
+ } else if (action === 'remote-remove') {
1152
+ argv = ['remote', 'remove', remote]
1153
+ } else {
1154
+ argv = ['remote', 'set-url', remote, url]
1155
+ }
1156
+
1157
+ const done = await git(args, argv, exec, { timeoutMs: 300000 })
1158
+ done.action = action
1159
+ return done
1160
+ },
1161
+ })
1162
+
1163
+ /* ─────────────── graph layout ─────────────── */
1164
+
1165
+ function layoutGraph(commits, maxLanes) {
1166
+ const lanes = []
1167
+ const rows = []
1168
+ let widest = 1
1169
+ for (let i = 0; i < commits.length; i += 1) {
1170
+ const commit = commits[i]
1171
+ let lane = lanes.indexOf(commit.hash)
1172
+ if (lane < 0) {
1173
+ lane = lanes.indexOf(null)
1174
+ if (lane < 0) {
1175
+ if (lanes.length >= maxLanes) lane = lanes.length - 1
1176
+ else { lanes.push(null); lane = lanes.length - 1 }
1177
+ }
1178
+ }
1179
+ lanes[lane] = null
1180
+ const edges = []
1181
+ for (let k = 0; k < commit.parents.length; k += 1) {
1182
+ const parent = commit.parents[k]
1183
+ let parentLane = lanes.indexOf(parent)
1184
+ if (parentLane < 0) {
1185
+ if (k === 0) { parentLane = lane; lanes[lane] = parent }
1186
+ else {
1187
+ parentLane = lanes.indexOf(null)
1188
+ if (parentLane < 0) {
1189
+ if (lanes.length >= maxLanes) continue
1190
+ lanes.push(parent)
1191
+ parentLane = lanes.length - 1
1192
+ } else lanes[parentLane] = parent
1193
+ }
1194
+ }
1195
+ edges.push({ hash: parent, lane: parentLane })
1196
+ if (parentLane + 1 > widest) widest = parentLane + 1
1197
+ }
1198
+ if (lane + 1 > widest) widest = lane + 1
1199
+ rows.push({ lane: lane, edges: edges })
1200
+ }
1201
+ return { rows: rows, lanes: widest }
1202
+ }
1203
+
1204
+ /* ── the graph under a filter ──
1205
+
1206
+ The plain layout below walks the commits it is handed, so it can only ever
1207
+ connect two of them: a parent that the filter hid is not in the list, and the
1208
+ lane booked for it is never claimed — the next commit then takes a *fresh*
1209
+ lane. Measured on the reader's repository with `fix` typed in the search box
1210
+ (200 matching commits): 104 lanes' worth of bookings, capped at 14, a 202px
1211
+ column that is mostly empty, 52% of the edges drawn as a one-row stub, and
1212
+ the dots marching right until they all pile onto the last lane. Same search,
1213
+ same repository, the graph was unreadable — while the plain history drew a
1214
+ proper ribbon.
1215
+
1216
+ So a filtered read is laid out on the **real** DAG: the host reads the plain
1217
+ hash+parents history for the span the matches cover (measured: 1518 commits
1218
+ in 190ms for those 200 rows), lays that out, and then places each visible
1219
+ commit on the lane it really holds. An edge is drawn to the nearest *visible*
1220
+ ancestor, and marked dashed when commits in between were filtered out —
1221
+ IDEA's own reading of a dashed line, and the reason its graph stays a graph
1222
+ when a filter is on. Nothing here changes an unfiltered read: that path still
1223
+ goes through layoutGraph alone. */
1224
+ const DAG_SKIP_MAX = 5000
1225
+
1226
+ function layoutVisible(full, commits, maxLanes) {
1227
+ const base = layoutGraph(full, maxLanes)
1228
+ const rowOf = {}
1229
+ for (let i = 0; i < full.length; i += 1) rowOf[full[i].hash] = i
1230
+ const visible = {}
1231
+ for (let i = 0; i < commits.length; i += 1) visible[commits[i].hash] = true
1232
+ const laneOf = function (hash) {
1233
+ const at = rowOf[hash]
1234
+ return at === undefined ? -1 : base.rows[at].lane
1235
+ }
1236
+
1237
+ const rows = []
1238
+ let widest = 1
1239
+ for (let i = 0; i < commits.length; i += 1) {
1240
+ const commit = commits[i]
1241
+ let lane = laneOf(commit.hash)
1242
+ /* A match that the span does not reach (it is older than the range the DAG
1243
+ read was allowed) has no lane of its own; it joins the row above rather
1244
+ than starting a lane the picture cannot justify. */
1245
+ if (lane < 0) lane = rows.length > 0 ? rows[rows.length - 1].lane : 0
1246
+ const edges = []
1247
+ for (let k = 0; k < commit.parents.length; k += 1) {
1248
+ const parent = commit.parents[k]
1249
+ let cur = parent
1250
+ let dashed = false
1251
+ let guard = 0
1252
+ while (rowOf[cur] !== undefined && visible[cur] !== true) {
1253
+ dashed = true
1254
+ const grand = full[rowOf[cur]].parents
1255
+ if (grand.length === 0) { cur = null; break }
1256
+ cur = grand[0]
1257
+ guard += 1
1258
+ if (guard > DAG_SKIP_MAX) { cur = null; break }
1259
+ }
1260
+ const reached = cur !== null && visible[cur] === true
1261
+ const target = reached ? laneOf(cur) : laneOf(parent)
1262
+ const edgeLane = target < 0 ? lane : target
1263
+ /* Whatever is left is a parent this list does not contain and cannot
1264
+ follow to one: the line leaves the page, and the client draws it that
1265
+ way instead of stopping it a row short. */
1266
+ edges.push({ hash: reached ? cur : parent, lane: edgeLane, dashed: reached ? dashed : true })
1267
+ if (edgeLane + 1 > widest) widest = edgeLane + 1
1268
+ }
1269
+ if (lane + 1 > widest) widest = lane + 1
1270
+ rows.push({ lane: lane, edges: edges })
1271
+ }
1272
+ return { rows: rows, lanes: widest }
1273
+ }
1274
+
1275
+ /* `git log --pretty=format:%H %P`, one commit per line: the plain history the
1276
+ filtered graph is laid out on. Only the shape of the DAG is needed, so this is
1277
+ the cheapest form of the same walk. */
1278
+ function parseDag(stdout) {
1279
+ const commits = []
1280
+ const lines = stdout.split('\n')
1281
+ for (let i = 0; i < lines.length; i += 1) {
1282
+ const line = lines[i].replace(/\r$/, '')
1283
+ if (line.length === 0) continue
1284
+ const parts = line.split(' ')
1285
+ const parents = []
1286
+ for (let k = 1; k < parts.length; k += 1) if (parts[k].length > 0) parents.push(parts[k])
1287
+ commits.push({ hash: parts[0], parents: parents })
1288
+ }
1289
+ return commits
1290
+ }
1291
+
1292
+ function parseCommitRecords(stdout) {
1293
+ const commits = []
1294
+ const records = stdout.split('\u001e')
1295
+ for (let i = 0; i < records.length; i += 1) {
1296
+ const record = records[i].replace(/^\n+/, '')
1297
+ if (record.length === 0) continue
1298
+ const fields = record.split('\u001f')
1299
+ const parents = fields[7] === undefined || fields[7].length === 0 ? [] : fields[7].split(' ')
1300
+ commits.push({
1301
+ hash: field(fields, 0),
1302
+ short: field(fields, 1),
1303
+ author: field(fields, 2),
1304
+ email: field(fields, 3),
1305
+ date: field(fields, 4),
1306
+ subject: field(fields, 5),
1307
+ refs: field(fields, 6),
1308
+ parents: parents,
1309
+ })
1310
+ }
1311
+ return commits
1312
+ }
1313
+
1314
+ /* ─────────────── Client RPC ─────────────── */
1315
+
1316
+ /* ── whose working directory this request means ──
1317
+
1318
+ A request that names no path of its own is about the session's own working
1319
+ directory, which is exactly the directory the shell layer already treats as
1320
+ home when no workdir is given. Both questions below are that one lookup: which
1321
+ repository this request is about, and where a probe of some *other* path may be
1322
+ spawned. A probe must never run with the inspected path as its workdir — when
1323
+ that directory does not exist the spawn itself fails before git is ever reached
1324
+ and the caller sees a rejected promise instead of the diagnosis it asked for. */
1325
+ function sessionWorkdir(input) {
1326
+ if (input == null || !isStr(input.sessionId)) return undefined
1327
+ const sessions = ctx.get('sessions')
1328
+ if (sessions === undefined) return undefined
1329
+ try {
1330
+ const session = sessions.get(input.sessionId)
1331
+ const header = session != null ? session.header : undefined
1332
+ const cwd = header != null ? header.cwd : undefined
1333
+ if (isStr(cwd) && cwd.length > 0) return cwd
1334
+ } catch (error) {
1335
+ console.error('dsh-git-idea: could not resolve the session working directory', String(error))
1336
+ }
1337
+ return undefined
1338
+ }
1339
+
1340
+ function repoFrom(input) {
1341
+ if (input != null && isStr(input.repo) && input.repo.trim().length > 0) return input.repo.trim()
1342
+ return sessionWorkdir(input)
1343
+ }
1344
+
1345
+ /* Which repository, and on whose behalf. The session id travels with the args so
1346
+ the shell layer can ask for that session's sandbox policy — it is the only
1347
+ thing that says whether these commands may write at all (see `sandboxFor`).
1348
+
1349
+ Every path that builds these args goes through here, including the ones that
1350
+ resolved a path of their own: a request that keeps the repo and drops the
1351
+ session id runs under the *deployment* default policy instead of the reader's
1352
+ own, which is how `git init` came to be denied on a directory nobody had a
1353
+ problem writing to. */
1354
+ function argsAt(input, repo) {
1355
+ const out = repo === undefined || repo === null ? {} : { repo: repo }
1356
+ if (input != null && isStr(input.sessionId) && input.sessionId.length > 0) out.sessionId = input.sessionId
1357
+ return out
1358
+ }
1359
+
1360
+ function argsFor(input) {
1361
+ return argsAt(input, repoFrom(input))
1362
+ }
1363
+
1364
+ /* ─────────────── per-repository read cache ───────────────
1365
+
1366
+ Opening the panel used to cost nine child processes and nothing was reused,
1367
+ so every open re-read the whole repository. Reads are now memoised per
1368
+ repository and dropped on any mutation.
1369
+
1370
+ Reads do not age out on a timer. Data may be as old as the last explicit
1371
+ invalidation, which is what makes reopening the panel instant; freshness is
1372
+ the watcher's job (git/watch below) plus the mutation and refresh paths.
1373
+
1374
+ A miss stores the *promise*, not the value: two surfaces asking for the same
1375
+ read at the same moment — the composer chip's full read, and the panel opening
1376
+ under it — are one child process instead of two, which on a slow mount is the
1377
+ difference between one seven-second `git status` and two. A rejection is never
1378
+ kept, so a read that failed can be attempted again, and the value is only
1379
+ stored if the entry is still the promise that produced it: an invalidation
1380
+ arriving mid-read must not be undone by that read landing afterwards. */
1381
+
1382
+ const readCache = new Map()
1383
+ const READ_CACHE_MAX = 300
1384
+
1385
+ async function cached(repo, tag, loader) {
1386
+ const key = repo + '\u0000' + tag
1387
+ const hit = readCache.get(key)
1388
+ if (hit !== undefined) return await hit
1389
+ const pending = loader()
1390
+ readCache.set(key, pending)
1391
+ try {
1392
+ const value = await pending
1393
+ if (readCache.get(key) === pending) readCache.set(key, value)
1394
+ return value
1395
+ } catch (error) {
1396
+ if (readCache.get(key) === pending) readCache.delete(key)
1397
+ throw error
1398
+ }
1399
+ }
1400
+
1401
+ function invalidateRepo(repo) {
1402
+ if (repo === undefined || repo === null) { readCache.clear(); return }
1403
+ const prefix = repo + '\u0000'
1404
+ const doomed = []
1405
+ readCache.forEach(function (_value, key) {
1406
+ if (key.indexOf(prefix) === 0) doomed.push(key)
1407
+ })
1408
+ for (let i = 0; i < doomed.length; i += 1) readCache.delete(doomed[i])
1409
+ /* Oldest first, one key at a time. Clearing the whole map on overflow meant one
1410
+ repository's paging dropped every other repository's reads with it. */
1411
+ while (readCache.size > READ_CACHE_MAX) readCache.delete(readCache.keys().next().value)
1412
+ }
1413
+
1414
+ /* ─────────────── the identity reads, and reading by pathspec ─────────────── */
1415
+
1416
+ /* Diagnostics for the "this path is not a repository" setup page. The workdir is
1417
+ the session's own directory, never the path being inspected (see
1418
+ `sessionWorkdir`). */
1419
+ async function probeShell(input, command) {
1420
+ return await invoke(command, argsAt(input, sessionWorkdir(input)), null, { timeoutMs: 20000 })
1421
+ }
1422
+
1423
+ async function pathKind(input, target) {
1424
+ const probe = await probeShell(input, 'if [ -d ' + shq(target) + ' ]; then echo dir; elif [ -f ' + shq(target) + ' ]; then echo file; else echo none; fi')
1425
+ const kind = probe.stdout.trim()
1426
+ return kind === 'dir' || kind === 'file' ? kind : 'none'
1427
+ }
1428
+
1429
+ /* One shell process answers everything the setup page needs: whether the path
1430
+ exists, what it is, git's own status with its exit code carried out
1431
+ explicitly, and whether an operation is caught half-done. The previous shape
1432
+ cost two or three spawns for the same information. */
1433
+ /* ── the cheap half of a panel read ──
1434
+
1435
+ `git status` stats every tracked file. On a Windows-mounted worktree of a few
1436
+ thousand files that is the whole cost of opening the panel: measured on one
1437
+ here, 7.0s for the status against 0.13s for the four commands below. Nothing
1438
+ in the composer chip, and nothing in the panel's frame, needs the working
1439
+ tree — they need to know whether this path is a repository, which branch it is
1440
+ on, where that branch stands against its upstream, and whether a cherry-pick,
1441
+ merge or rebase is half-done. The working tree is asked for separately, by the
1442
+ request that actually shows it. */
1443
+ /* ── one directory, no discovery ──
1444
+
1445
+ A workspace is a repository when **that directory** is one: `$dir/.git` (a
1446
+ directory, or the file a worktree and a submodule keep there). Git's own
1447
+ discovery would instead walk up and answer for whatever repository happens to
1448
+ be above — the panel would name another project's branch, its watcher would
1449
+ follow that repository's refs, and the answer for a bare subdirectory would
1450
+ silently be about a tree the reader never pointed at. So every command below
1451
+ tests that one path and stops there; nothing looks upward, and nothing looks
1452
+ into the directory either. */
1453
+ function repoHere(target) {
1454
+ return '[ -e ' + shq(target) + '/.git ]'
1455
+ }
1456
+
1457
+ /* The five states in which an operation is caught half-done. Both reads below ask
1458
+ for all of them, and they have to stay in step: a state known to one and not
1459
+ the other is a panel that offers "continue" on one screen and not the next. */
1460
+ function sequencerShell() {
1461
+ return [
1462
+ " [ -e \"$gd/CHERRY_PICK_HEAD\" ] && printf 'S:cherry-pick\\n'",
1463
+ " [ -e \"$gd/REVERT_HEAD\" ] && printf 'S:revert\\n'",
1464
+ " [ -e \"$gd/MERGE_HEAD\" ] && printf 'S:merge\\n'",
1465
+ " [ -d \"$gd/rebase-merge\" ] && printf 'S:rebase\\n'",
1466
+ " [ -d \"$gd/rebase-apply\" ] && printf 'S:rebase\\n'",
1467
+ ].join('\n')
1468
+ }
1469
+
1470
+ /* The branch half of the identity read, in one git process instead of three.
1471
+
1472
+ The branch name comes out of `$gd/HEAD` itself rather than out of
1473
+ `symbolic-ref`: the gitdir is already in hand, the answer is the one line in
1474
+ that file, and the spawn cost a quarter of this read. Reading it also answers
1475
+ the case for-each-ref cannot — a repository whose branch has no commit yet
1476
+ names that branch, while `refs/heads` is still empty.
1477
+
1478
+ Then one for-each-ref, restricted to that branch, carries the upstream and the
1479
+ ahead/behind numbers together. Asking for them separately re-walked the same
1480
+ ref table for nothing, and asking for *all* branches instead is far worse than
1481
+ it looks: `%(upstream:track)` costs a rev-list pair per branch, 109ms against
1482
+ 31ms for the single branch on screen on the repository this was measured
1483
+ against.
1484
+
1485
+ LC_ALL=C is not decoration either: the words around those numbers are
1486
+ translated, and this is the read the composer chip shows. */
1487
+ function branchIdentityShell(target) {
1488
+ return [
1489
+ " b=''",
1490
+ ' if [ -f "$gd/HEAD" ]; then',
1491
+ ' read -r headline < "$gd/HEAD"',
1492
+ ' case "$headline" in',
1493
+ " 'ref: refs/heads/'*) b=${headline#'ref: refs/heads/'} ;;",
1494
+ ' esac',
1495
+ ' fi',
1496
+ ' if [ -n "$b" ]; then',
1497
+ " printf 'B:%s\\n' \"$b\"",
1498
+ " printf 'U:%s\\n' \"$(LC_ALL=C git -C " + shq(target) + " for-each-ref --format='%(upstream:short)%1f%(upstream:track)' \"refs/heads/$b\" 2>/dev/null)\"",
1499
+ ' fi',
1500
+ ].join('\n')
1501
+ }
1502
+
1503
+ /* The shape both reads share: the three answers about the path itself, the one
1504
+ gitdir both of them need, and the half-done-operation markers — with everything
1505
+ that is actually asked of the repository in the middle. Written once because
1506
+ the two commands have to keep answering the same way about the same path, and
1507
+ only their middles should ever differ. */
1508
+ function pathShell(target, middle) {
1509
+ const quoted = shq(target)
1510
+ return [
1511
+ 'if [ -d ' + quoted + ' ]; then',
1512
+ " printf 'K:dir\\n'",
1513
+ /* Guarded by `repoHere` and not left to git: without the guard,
1514
+ `git rev-parse` in a directory that is not a repository walks up and
1515
+ answers for a parent one. */
1516
+ ' if ' + repoHere(target) + '; then',
1517
+ ' gd=$(git -C ' + quoted + ' rev-parse --absolute-git-dir 2>/dev/null)',
1518
+ ' else',
1519
+ " gd=''",
1520
+ ' fi',
1521
+ middle,
1522
+ 'elif [ -f ' + quoted + ' ]; then',
1523
+ " printf 'K:file\\n'",
1524
+ 'else',
1525
+ " printf 'K:none\\n'",
1526
+ 'fi',
1527
+ ].join('\n')
1528
+ }
1529
+
1530
+ function panelIdentityCommand(target) {
1531
+ return pathShell(target, [
1532
+ ' if [ -z "$gd" ]; then',
1533
+ " printf 'RC:1\\n'",
1534
+ " printf 'fatal: not a git repository\\n'",
1535
+ ' else',
1536
+ branchIdentityShell(target),
1537
+ sequencerShell(),
1538
+ " printf 'RC:0\\n'",
1539
+ ' fi',
1540
+ ].join('\n'))
1541
+ }
1542
+
1543
+ /* ── reading by pathspec instead of by tree ──
1544
+
1545
+ `git status` stats every tracked file and walks every untracked directory, and
1546
+ on a Windows-mounted worktree that is the whole cost of every read this plugin
1547
+ makes. Measured on the reader's repository, one whole-tree status: 7.4s
1548
+ (13.6s cold), 5.3s with `-uno`, 13.0s with `--untracked-files=all`. The same
1549
+ command asked about the 36 paths the changes tree was showing: **0.5s**. The
1550
+ working tree is what the panel is showing, so the reads that keep it fresh ask
1551
+ about those paths, and only a read that has to answer for the whole tree pays
1552
+ for the whole tree.
1553
+
1554
+ The paths come from the client, so each one passes the guard the diff read
1555
+ uses (relative, inside the repository, no NUL), and the list is capped: a
1556
+ pathspec list is a command line, and a command line has a length. */
1557
+ const READ_PATHS_MAX = 200
1558
+
1559
+ /* How much plain history a filtered graph may lay itself out on. Measured on the
1560
+ reader's repository: 200 `fix` matches span 1518 commits, read in 190ms. The
1561
+ cap is what keeps a search that matches once per thousand commits from turning
1562
+ a keystroke into a whole-history walk — past it, edges leave the page. */
1563
+ const DAG_MAX = 4000
1564
+
1565
+ function readPaths(input) {
1566
+ if (input == null || !Array.isArray(input.paths)) return []
1567
+ const out = []
1568
+ for (let i = 0; i < input.paths.length && out.length < READ_PATHS_MAX; i += 1) {
1569
+ const path = input.paths[i]
1570
+ if (!isStr(path) || path.length === 0) continue
1571
+ if (repoRelativePath(path) !== '') continue
1572
+ if (out.indexOf(path) >= 0) continue
1573
+ out.push(path)
1574
+ }
1575
+ return out
1576
+ }
1577
+
1578
+ function pathspecSuffix(paths) {
1579
+ if (paths.length === 0) return ''
1580
+ return ' -- ' + paths.map(shq).join(' ')
1581
+ }
1582
+
1583
+ function panelCommand(target, paths) {
1584
+ const quoted = shq(target)
1585
+ const asked = paths == null ? [] : paths
1586
+ return pathShell(target, [
1587
+ ' if ' + repoHere(target) + '; then',
1588
+ " out=$(git -C " + quoted + " --no-optional-locks -c core.quotePath=false status --porcelain=v2 --branch --untracked-files=normal" + pathspecSuffix(asked) + " 2>&1); rc=$?",
1589
+ ' else',
1590
+ " out='fatal: not a git repository'; rc=1",
1591
+ ' fi',
1592
+ " printf '%s\n' \"$out\"",
1593
+ " printf 'RC:%s\n' \"$rc\"",
1594
+ ' if [ -n "$gd" ]; then',
1595
+ sequencerShell(),
1596
+ ' fi',
1597
+ ].join('\n'))
1598
+ }
1599
+
1600
+ /* One lightweight spawn answers "did anything change?" for the client watcher.
1601
+ It deliberately uses --untracked-files=normal: the per-file walk of `all` is
1602
+ the expensive part on a large tree, and a collapsed untracked directory still
1603
+ changes the signature when its contents do. The ref table and the HEAD file
1604
+ cover what the status walk cannot see: a new commit with no worktree change,
1605
+ and — the one the working tree is silent about — a branch switch, whether it
1606
+ was made here or in a terminal next to us.
1607
+
1608
+ --no-optional-locks is load-bearing, not decoration: a plain `git status`
1609
+ refreshes the index cache and takes .git/index.lock to do it, so a background
1610
+ poller racing the user's own `git add` makes THEIR command fail. Measured on
1611
+ this machine: 15 of 150 adds failed without the flag, 0 of 150 with it, and
1612
+ the reported status is identical either way.
1613
+
1614
+ The rule is not "this one call": it is every read this plugin makes. The full
1615
+ panel read and the `git_status` tool missed it for a while and were caught by
1616
+ a case of exactly this — a fetch that triggered auto-gc was blamed first, but
1617
+ background gc never touches index.lock (it runs pack-objects --indexed-objects,
1618
+ which only reads the index). Whoever writes .git/index is the suspect, and a
1619
+ `git status` writes it. `test/gp34a` now holds both the rule and the probe. */
1620
+ /* What "did anything move?" costs. The status is the expensive part — seconds
1621
+ on a slow mount, every tick — and it is only worth paying while something is
1622
+ showing the working tree, which is what `deep` asks for. Everything else in
1623
+ the signature is three stats, one for-each-ref and one small file read. */
1624
+ function watchCommand(target, deep, paths) {
1625
+ const quoted = shq(target)
1626
+ const asked = paths == null ? [] : paths
1627
+ /* Every git call below is inside `[ -n "$gd" ]`: on a directory that is not a
1628
+ repository, git would happily answer for one of its parents, and the
1629
+ signature would then follow a tree this workspace does not own. */
1630
+ const whenRepo = function (command) {
1631
+ return '$(if [ -n "$gd" ]; then ' + command + '; fi)'
1632
+ }
1633
+ const out = [
1634
+ "st() { stat -c '%Y:%s' \"$1\" 2>/dev/null || stat -f '%m:%z' \"$1\" 2>/dev/null; }",
1635
+ 'if ' + repoHere(target) + '; then',
1636
+ ' gd=$(git -C ' + quoted + ' rev-parse --absolute-git-dir 2>/dev/null)',
1637
+ 'else',
1638
+ " gd=''",
1639
+ 'fi',
1640
+ ]
1641
+ if (deep === true) {
1642
+ /* ── the tick asks about the paths on screen ──
1643
+ This runs every few seconds while the changes tab is open, and a
1644
+ whole-tree status is 7.4s on the reader's mount: the tick then takes
1645
+ longer than the interval between ticks, so the poller never stops and
1646
+ every cheap read beside it (for-each-ref went 50ms → 143ms) waits behind
1647
+ it. The same command over the paths the tree was showing is 0.5s. A file
1648
+ that was clean and is now modified is the one thing this cannot see; the
1649
+ panel reads the whole tree for that on its own clock. */
1650
+ out.push('if [ -n "$gd" ]; then git -C ' + quoted + ' --no-optional-locks -c core.quotePath=false status --porcelain=v2 --branch --untracked-files=normal' + pathspecSuffix(asked) + ' 2>&1; fi')
1651
+ }
1652
+ out.push(
1653
+ "printf 'F:%s\\n' \"" + whenRepo("git -C " + quoted + " for-each-ref --format='%(refname):%(objectname)' refs/heads refs/remotes 2>/dev/null") + "\"",
1654
+ "printf 'H:%s\\n' \"" + whenRepo("git -C " + quoted + " rev-parse -q --verify HEAD 2>/dev/null") + "\"",
1655
+ "printf 'I:%s\\n' \"$(st \"$gd/index\")\"",
1656
+ /* The HEAD *file*, not just its stamp. Two branches can point at the same
1657
+ commit — `git switch -c` always does, and so does any pair left level by a
1658
+ fast-forward — and then the ref table, the HEAD sha and often the index are
1659
+ byte-identical, so the branch name written in this file is the only thing
1660
+ that tells them apart. A stamp is not enough on its own either: it carries
1661
+ second resolution, so a switch made in the same second as the previous read
1662
+ looks like nothing happened — and then never becomes visible at all. */
1663
+ "printf 'R:%s\\n' \"$(cat \"$gd/HEAD\" 2>/dev/null) $(st \"$gd/HEAD\")\"",
1664
+ "printf 'P:%s\\n' \"$(st \"$gd/packed-refs\")\"",
1665
+ )
1666
+ return out.join('\n')
1667
+ }
1668
+
1669
+ /* ─────────────── the panel's own read, and the graph ─────────────── */
1670
+
1671
+ function missingPanel(target, reason) {
1672
+ return {
1673
+ ok: false, repo: target, error: 'not-a-repository', reason: reason,
1674
+ stderr: '', exitCode: null, staged: [], unstaged: [], untracked: [], unmerged: [],
1675
+ }
1676
+ }
1677
+
1678
+ async function readPanelIdentity(input, target) {
1679
+ const probe = await probeShell(input, panelIdentityCommand(target))
1680
+ const lines = probe.stdout.split('\n')
1681
+ const kind = lines.length > 0 ? lines[0] : ''
1682
+ if (kind === 'K:file') return missingPanel(target, 'file')
1683
+ if (kind !== 'K:dir') return missingPanel(target, 'missing')
1684
+
1685
+ let exitCode = null
1686
+ let sequencer = null
1687
+ let branch = null
1688
+ let upstream = null
1689
+ let track = ''
1690
+ for (let i = 1; i < lines.length; i += 1) {
1691
+ const line = lines[i]
1692
+ if (line.indexOf('RC:') === 0) { exitCode = parseInt(line.slice(3), 10); continue }
1693
+ if (line.indexOf('S:') === 0) { if (sequencer === null) sequencer = line.slice(2); continue }
1694
+ if (line.indexOf('B:') === 0) { branch = line.slice(2); continue }
1695
+ /* The upstream and its standing arrive in one line, separated by the same
1696
+ \u001f the rest of the Host uses: one for-each-ref answers both. */
1697
+ if (line.indexOf('U:') === 0) {
1698
+ const fields = line.slice(2).split('\u001f')
1699
+ upstream = field(fields, 0)
1700
+ track = field(fields, 1)
1701
+ }
1702
+ }
1703
+ if (exitCode !== 0) {
1704
+ const failed = missingPanel(target, 'not-a-repo')
1705
+ failed.exitCode = exitCode
1706
+ failed.stderr = ''
1707
+ return failed
1708
+ }
1709
+ const counts = trackCounts(track)
1710
+ return {
1711
+ ok: true, repo: target, branch: branch, detached: branch === null,
1712
+ upstream: upstream !== null && upstream.length > 0 ? upstream : null,
1713
+ ahead: counts.ahead, behind: counts.behind,
1714
+ sequencer: sequencer,
1715
+ staged: [], unstaged: [], untracked: [], unmerged: [],
1716
+ /* Says outright that the working tree was not read, so nothing downstream
1717
+ can mistake "no changes" for "not asked". */
1718
+ partial: true,
1719
+ }
1720
+ }
1721
+
1722
+ async function readPanel(input, target, paths) {
1723
+ const asked = paths == null ? [] : paths
1724
+ const probe = await probeShell(input, panelCommand(target, asked))
1725
+ const lines = probe.stdout.split('\n')
1726
+ const kind = lines.length > 0 ? lines[0] : ''
1727
+ if (kind === 'K:file') return missingPanel(target, 'file')
1728
+ if (kind !== 'K:dir') return missingPanel(target, 'missing')
1729
+
1730
+ let exitCode = null
1731
+ let sequencer = null
1732
+ const body = []
1733
+ for (let i = 1; i < lines.length; i += 1) {
1734
+ const line = lines[i]
1735
+ if (line.indexOf('RC:') === 0) { exitCode = parseInt(line.slice(3), 10); continue }
1736
+ if (line.indexOf('S:') === 0) { if (sequencer === null) sequencer = line.slice(2); continue }
1737
+ body.push(line)
1738
+ }
1739
+ const output = body.join('\n')
1740
+
1741
+ if (exitCode !== 0) {
1742
+ const outsideRepo = output.indexOf('not a git repository') >= 0
1743
+ const failed = missingPanel(target, outsideRepo ? 'not-a-repo' : 'git-error')
1744
+ failed.exitCode = exitCode
1745
+ failed.stderr = outsideRepo ? '' : output
1746
+ return failed
1747
+ }
1748
+
1749
+ const parsed = parseStatusV2(output)
1750
+ const untracked = []
1751
+ for (let i = 0; i < parsed.untracked.length; i += 1) untracked.push({ path: parsed.untracked[i], code: '??' })
1752
+ const reply = {
1753
+ ok: true, repo: target, branch: parsed.detached ? null : parsed.branch, detached: parsed.detached,
1754
+ upstream: parsed.upstream, ahead: parsed.ahead, behind: parsed.behind,
1755
+ sequencer: sequencer,
1756
+ staged: parsed.staged, unstaged: parsed.unstaged, untracked: untracked, unmerged: parsed.unmerged,
1757
+ }
1758
+ /* A pathspec answer is about those paths and nothing else. It says so, and it
1759
+ names them, so the client can fold it into the snapshot it already has
1760
+ instead of mistaking it for the whole working tree. The whole-tree answer
1761
+ carries neither key — the same convention the identity read uses. */
1762
+ if (asked.length > 0) {
1763
+ reply.partial = true
1764
+ reply.paths = asked
1765
+ }
1766
+ return reply
1767
+ }
1768
+
1769
+ async function panelSnapshot(input) {
1770
+ const target = repoFrom(input, null)
1771
+ if (target === undefined) return missingPanel(null, 'no-path')
1772
+ /* Two tags, never one: a cheap answer cached under the full read's name would
1773
+ hand an empty working tree to the changes tab. */
1774
+ if (input != null && input.quick === true) {
1775
+ return await cached(target, 'panel-ident', function () { return readPanelIdentity(input, target) })
1776
+ }
1777
+ const paths = readPaths(input)
1778
+ if (paths.length > 0) {
1779
+ /* A partial answer is cached under the question it answered: same paths, same
1780
+ tag. Any mutation drops it with the rest of this repository's entries. */
1781
+ return await cached(target, 'panel|' + paths.join('\u0001'), function () { return readPanel(input, target, paths) })
1782
+ }
1783
+ return await cached(target, 'panel', function () { return readPanel(input, target, []) })
1784
+ }
1785
+
1786
+ async function readGraph(input, repo) {
1787
+ const args = argsFor(input)
1788
+ const requested = input != null && typeof input.maxCount === 'number' && input.maxCount > 0 ? Math.floor(input.maxCount) : 200
1789
+ const maxCount = requested > 20000 ? 20000 : requested
1790
+
1791
+ const head = await git(args, ['-c', 'core.quotePath=false', 'symbolic-ref', '--quiet', '--short', 'HEAD'], null, {})
1792
+ const currentBranch = head.exitCode === 0 ? head.stdout.trim() : null
1793
+ const allRefs = input != null && input.allRefs === true
1794
+ const asked = input != null && isStr(input.ref) && input.ref.trim().length > 0 ? input.ref.trim() : null
1795
+ const ref = asked !== null ? asked : (currentBranch !== null && currentBranch.length > 0 ? currentBranch : 'HEAD')
1796
+
1797
+ const search = input != null && isStr(input.search) ? input.search.trim() : ''
1798
+ const author = input != null && isStr(input.author) ? input.author.trim() : ''
1799
+ const since = input != null && isStr(input.since) ? input.since.trim() : ''
1800
+ const until = input != null && isStr(input.until) ? input.until.trim() : ''
1801
+ const path = input != null && isStr(input.path) ? input.path.trim() : ''
1802
+
1803
+ /* The two switches the log's search box carries, both off by default so the
1804
+ plain search is unchanged: `regex` hands the text to extended regexp
1805
+ instead of matching it literally, and `caseSensitive` drops git's `-i`. */
1806
+ const regex = input != null && input.regex === true
1807
+ const caseSensitive = input != null && input.caseSensitive === true
1808
+
1809
+ /* One commit more than asked for: the extra row is not returned, it is how
1810
+ "there is more history" is answered without a second read. */
1811
+ const argv = ['-c', 'core.quotePath=false', 'log', '--max-count=' + String(maxCount + 1), '--date-order',
1812
+ '--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s%x1f%D%x1f%P%x1e']
1813
+ if ((search.length > 0 && regex !== true) || author.length > 0) argv.push('--fixed-strings')
1814
+ if (search.length > 0) {
1815
+ argv.push('--grep=' + search)
1816
+ if (regex === true) argv.push('--extended-regexp')
1817
+ if (caseSensitive !== true) argv.push('-i')
1818
+ }
1819
+ if (author.length > 0) argv.push('--author=' + author)
1820
+ if (since.length > 0) argv.push('--since=' + since)
1821
+ if (until.length > 0) argv.push('--until=' + until)
1822
+ if (allRefs) argv.push('--all')
1823
+ else argv.push(ref)
1824
+ if (path.length > 0) { argv.push('--'); argv.push(path) }
1825
+
1826
+ const logged = await git(args, argv, null, {})
1827
+ if (logged.exitCode !== 0) {
1828
+ return { ok: false, repo: repo === undefined ? null : repo, error: 'not-a-repository', stderr: logged.stderr, currentBranch: currentBranch, ref: ref, commits: [], rows: [], lanes: 1 }
1829
+ }
1830
+ const parsed = parseCommitRecords(logged.stdout)
1831
+ const hasMore = parsed.length > maxCount
1832
+ const commits = hasMore ? parsed.slice(0, maxCount) : parsed
1833
+ /* ── a filtered list is laid out on the history it was filtered out of ──
1834
+
1835
+ Anything that *hides* commits — the search box, an author, a date range, a
1836
+ path — leaves the plain layout with parents it cannot see, and the lanes it
1837
+ books for them are never claimed (see layoutVisible). So the plain
1838
+ hash+parents history is read for the span those matches cover: everything
1839
+ above the oldest match, plus the oldest itself (its parents came with the
1840
+ filtered read). `--not <oldest>` is exactly that span, and it is what keeps
1841
+ this read proportional to the answer instead of to the repository.
1842
+
1843
+ The scope is the same as the filtered read's — `--all` or the one ref — and
1844
+ deliberately carries none of the hiding flags: this is the history, not the
1845
+ answer. `DAG_MAX` bounds the walk for a search that matches rarely; past it
1846
+ an edge simply leaves the page, which is what the unfiltered graph does at
1847
+ the end of a page too. */
1848
+ const hiding = search.length > 0 || author.length > 0 || since.length > 0 || until.length > 0 || path.length > 0
1849
+ let layout = layoutGraph(commits, 14)
1850
+ if (hiding && commits.length > 0) {
1851
+ const oldest = commits[commits.length - 1].hash
1852
+ const dagArgv = ['-c', 'core.quotePath=false', 'log', '--date-order', '--max-count=' + String(DAG_MAX),
1853
+ '--pretty=format:%H %P']
1854
+ if (allRefs) dagArgv.push('--all')
1855
+ else dagArgv.push(ref)
1856
+ dagArgv.push('--not')
1857
+ dagArgv.push(oldest)
1858
+ const dagged = await git(args, dagArgv, null, {})
1859
+ if (dagged.exitCode === 0) {
1860
+ const full = parseDag(dagged.stdout)
1861
+ const last = commits[commits.length - 1]
1862
+ full.push({ hash: last.hash, parents: last.parents })
1863
+ layout = layoutVisible(full, commits, 14)
1864
+ }
1865
+ }
1866
+ return {
1867
+ ok: true, repo: logged.cwd, currentBranch: currentBranch, ref: ref, allRefs: allRefs,
1868
+ commits: commits, rows: layout.rows, lanes: layout.lanes, hasMore: hasMore, maxCount: maxCount,
1869
+ }
1870
+ }
1871
+
1872
+ function graphTag(input) {
1873
+ const part = function (value) { return value != null && isStr(value) ? value : '' }
1874
+ return 'graph|' + [
1875
+ input != null && input.allRefs === true ? '1' : '0',
1876
+ part(input != null ? input.ref : null),
1877
+ part(input != null ? input.search : null),
1878
+ input != null && input.regex === true ? 're' : '',
1879
+ input != null && input.caseSensitive === true ? 'cs' : '',
1880
+ part(input != null ? input.author : null),
1881
+ part(input != null ? input.since : null),
1882
+ part(input != null ? input.until : null),
1883
+ part(input != null ? input.path : null),
1884
+ input != null && typeof input.maxCount === 'number' ? String(input.maxCount) : '',
1885
+ ].join('\u0001')
1886
+ }
1887
+
1888
+ async function graphSnapshot(input) {
1889
+ const repo = repoFrom(input, null)
1890
+ if (repo === undefined) return await readGraph(input, undefined)
1891
+ return await cached(repo, graphTag(input), function () { return readGraph(input, repo) })
1892
+ }
1893
+
1894
+ /* ─────────────── authors, refs, branches, and switching ─────────────── */
1895
+
1896
+ async function readAuthors(input, repo) {
1897
+ const args = argsFor(input)
1898
+ const listed = await git(args, ['--no-pager', 'shortlog', '-sne', '--all'], null, { maxBytes: 200000 })
1899
+ if (listed.exitCode !== 0) {
1900
+ return { ok: false, repo: repo === undefined ? null : repo, error: 'not-a-repository', stderr: listed.stderr, authors: [] }
1901
+ }
1902
+ const authors = []
1903
+ const rows = listed.stdout.split('\n')
1904
+ for (let i = 0; i < rows.length && authors.length < 120; i += 1) {
1905
+ const row = rows[i]
1906
+ if (row.length === 0) continue
1907
+ const tab = row.indexOf('\t')
1908
+ if (tab < 0) continue
1909
+ const count = parseInt(row.slice(0, tab).trim(), 10) || 0
1910
+ const who = row.slice(tab + 1).trim()
1911
+ if (who.length === 0) continue
1912
+ const open = who.lastIndexOf('<')
1913
+ const close = who.lastIndexOf('>')
1914
+ const name = open > 0 ? who.slice(0, open).trim() : who
1915
+ const email = open >= 0 && close > open ? who.slice(open + 1, close).trim() : ''
1916
+ authors.push({ name: name, email: email, count: count })
1917
+ }
1918
+ return { ok: true, repo: listed.cwd, authors: authors }
1919
+ }
1920
+
1921
+ /* shortlog walks the entire history for every author, so it is the most
1922
+ expensive read there is. It is deferred by the client to the history tab and
1923
+ then held until something invalidates it. */
1924
+ async function authorsSnapshot(input) {
1925
+ const repo = repoFrom(input, null)
1926
+ if (repo === undefined) return await readAuthors(input, undefined)
1927
+ return await cached(repo, 'authors', function () { return readAuthors(input, repo) })
1928
+ }
1929
+
1930
+ async function readRefs(input, repo) {
1931
+ const args = argsFor(input)
1932
+ /* The tracking columns matter as much as the names here: the tree is where a
1933
+ branch and its standing against its upstream are seen together, and the
1934
+ atoms are free once the command is running anyway. */
1935
+ /* `gitC`, not `git`: %(upstream:track) is a translated string and parsing it
1936
+ needs the same pinned locale the branch list already runs under. */
1937
+ const listed = await gitC(args, ['-c', 'core.quotePath=false', 'for-each-ref',
1938
+ '--format=%(refname)%1f%(refname:short)%1f%(HEAD)%1f%(objectname:short)%1f%(upstream:short)%1f%(upstream:track)%1f%(committerdate:unix)',
1939
+ 'refs/heads', 'refs/remotes'], null, {})
1940
+ if (listed.exitCode !== 0) {
1941
+ return { ok: false, repo: repo === undefined ? null : repo, error: 'not-a-repository', stderr: listed.stderr, current: [], local: [], remote: [] }
1942
+ }
1943
+ const local = []
1944
+ const current = []
1945
+ const remoteMap = new Map()
1946
+ const rows = listed.stdout.split('\n')
1947
+ for (let i = 0; i < rows.length; i += 1) {
1948
+ if (rows[i].length === 0) continue
1949
+ const fields = rows[i].split('\u001f')
1950
+ const full = field(fields, 0)
1951
+ const short = field(fields, 1)
1952
+ const isCurrent = fields[2] === '*'
1953
+ if (full.indexOf('refs/heads/') === 0) {
1954
+ const counts = trackCounts(field(fields, 5))
1955
+ local.push({
1956
+ segments: short.split('/'), data: short,
1957
+ upstream: field(fields, 4),
1958
+ ahead: counts.ahead, behind: counts.behind,
1959
+ at: parseInt(fields[6], 10) || 0,
1960
+ })
1961
+ if (isCurrent) current.push(short)
1962
+ } else if (full.indexOf('refs/remotes/') === 0) {
1963
+ const slash = short.indexOf('/')
1964
+ const remoteName = slash < 0 ? short : short.slice(0, slash)
1965
+ const rest = slash < 0 ? short : short.slice(slash + 1)
1966
+ if (rest.length === 0 || rest === 'HEAD') continue
1967
+ if (!remoteMap.has(remoteName)) remoteMap.set(remoteName, [])
1968
+ remoteMap.get(remoteName).push({ segments: rest.split('/'), data: short })
1969
+ }
1970
+ }
1971
+ const remote = []
1972
+ remoteMap.forEach(function (entries, name) { remote.push({ name: name, refs: entries }) })
1973
+ return { ok: true, repo: listed.cwd, current: current, local: local, remote: remote }
1974
+ }
1975
+
1976
+ async function refsSnapshot(input) {
1977
+ const repo = repoFrom(input, null)
1978
+ if (repo === undefined) return await readRefs(input, undefined)
1979
+ return await cached(repo, 'refs', function () { return readRefs(input, repo) })
1980
+ }
1981
+
1982
+ /* "[ahead 2, behind 3]", "[behind 3]", "[gone]" — the words are English because
1983
+ gitC pins the locale, and only the numbers are wanted here. */
1984
+ function trackCounts(raw) {
1985
+ const out = { ahead: 0, behind: 0 }
1986
+ const text = raw == null ? '' : String(raw)
1987
+ const a = text.indexOf('ahead ')
1988
+ if (a >= 0) out.ahead = parseInt(text.slice(a + 6), 10) || 0
1989
+ const b = text.indexOf('behind ')
1990
+ if (b >= 0) out.behind = parseInt(text.slice(b + 7), 10) || 0
1991
+ return out
1992
+ }
1993
+
1994
+ /* The branch switcher's own list, which the panel's sidebar is not: it is
1995
+ ordered by when each branch last moved, and carries the things a chooser needs
1996
+ but a tree does not — how long ago it moved, what it tracks, how far ahead or
1997
+ behind that is, and where "the previous branch" is, so one row can undo a
1998
+ mistaken switch.
1999
+
2000
+ Local and remote heads come from the same for-each-ref, so grouping them costs
2001
+ nothing extra. A remote branch that already has a local branch of the same
2002
+ name is dropped: the local row already names it as its upstream, and two rows
2003
+ that both mean "dev" would be one row too many.
2004
+
2005
+ Two processes, once per repository per invalidation: the list, and rev-parse
2006
+ for @{-1}, which exits 128 (not a failure worth reporting) in a repository
2007
+ where nothing has been checked out yet. */
2008
+ async function readBranches(input, repo) {
2009
+ const args = argsFor(input)
2010
+ const listed = await gitC(args, ['-c', 'core.quotePath=false', 'for-each-ref',
2011
+ '--format=%(refname)%1f%(refname:short)%1f%(HEAD)%1f%(committerdate:unix)%1f%(upstream:short)%1f%(upstream:trackshort)%1f%(upstream:track)%1f%(objectname:short)%1f%(contents:subject)',
2012
+ '--sort=-committerdate', 'refs/heads', 'refs/remotes'], null, {})
2013
+ if (listed.exitCode !== 0) {
2014
+ return { ok: false, repo: repo === undefined ? null : repo, error: 'not-a-repository', stderr: listed.stderr, current: '', previous: '', branches: [], remotes: [] }
2015
+ }
2016
+ const branches = []
2017
+ const remoteRows = []
2018
+ const localNames = []
2019
+ let current = ''
2020
+ const rows = listed.stdout.split('\n')
2021
+ for (let i = 0; i < rows.length; i += 1) {
2022
+ if (rows[i].length === 0) continue
2023
+ const fields = rows[i].split('\u001f')
2024
+ const full = field(fields, 0)
2025
+ const short = field(fields, 1)
2026
+ if (short.length === 0) continue
2027
+ const isCurrent = fields[2] === '*'
2028
+ /* parseInt and a truthiness test rather than Number/isFinite: the restricted
2029
+ Host realm is not the full JavaScript global scope, and parseInt is the one
2030
+ converter the rest of this file already relies on. */
2031
+ const stamp = parseInt(field(fields, 3), 10)
2032
+ /* trackshort is symbols only (=, >, <, <>) and is never translated; the
2033
+ numbers beside it come from :track, whose words are pinned to C by gitC. */
2034
+ const counts = trackCounts(field(fields, 6))
2035
+ const entry = {
2036
+ name: short,
2037
+ current: isCurrent,
2038
+ committedAt: stamp > 0 ? stamp : 0,
2039
+ upstream: field(fields, 4),
2040
+ track: field(fields, 5),
2041
+ ahead: counts.ahead,
2042
+ behind: counts.behind,
2043
+ head: field(fields, 7),
2044
+ subject: field(fields, 8),
2045
+ }
2046
+ if (full.indexOf('refs/heads/') === 0) {
2047
+ if (isCurrent) current = short
2048
+ localNames.push(short)
2049
+ branches.push(entry)
2050
+ } else if (full.indexOf('refs/remotes/') === 0) {
2051
+ const slash = short.indexOf('/')
2052
+ if (slash <= 0) continue
2053
+ const rest = short.slice(slash + 1)
2054
+ if (rest.length === 0 || rest === 'HEAD') continue
2055
+ remoteRows.push({
2056
+ name: rest, remote: short.slice(0, slash), ref: short,
2057
+ committedAt: entry.committedAt, head: entry.head, subject: entry.subject,
2058
+ ahead: 0, behind: 0,
2059
+ })
2060
+ }
2061
+ }
2062
+ const remotes = []
2063
+ for (let i = 0; i < remoteRows.length; i += 1) {
2064
+ if (localNames.indexOf(remoteRows[i].name) < 0) remotes.push(remoteRows[i])
2065
+ }
2066
+ const prev = await git(args, ['rev-parse', '--abbrev-ref', '@{-1}'], null, {})
2067
+ const previous = prev.exitCode === 0 ? prev.stdout.trim() : ''
2068
+ return {
2069
+ ok: true, repo: listed.cwd, current: current,
2070
+ previous: previous === 'HEAD' || previous === current ? '' : previous,
2071
+ branches: branches, remotes: remotes,
2072
+ }
2073
+ }
2074
+
2075
+ async function branchesSnapshot(input) {
2076
+ const repo = repoFrom(input, null)
2077
+ if (repo === undefined) return await readBranches(input, undefined)
2078
+ return await cached(repo, 'branches', function () { return readBranches(input, repo) })
2079
+ }
2080
+
2081
+ async function previousBranch(input) {
2082
+ const prev = await git(argsFor(input), ['rev-parse', '--abbrev-ref', '@{-1}'], null, {})
2083
+ if (prev.exitCode !== 0) return ''
2084
+ const name = prev.stdout.trim()
2085
+ return name === 'HEAD' ? '' : name
2086
+ }
2087
+
2088
+ /* Switching with local changes in the way. git itself decides whether the
2089
+ changes actually conflict, so the cheap path is to try the switch first and
2090
+ only offer to stash when git refuses; stashing unconditionally would turn
2091
+ every switch of a dirty tree into two extra writes and one more chance to
2092
+ conflict on the way back.
2093
+
2094
+ When the caller does ask for the stash, the failure paths matter more than
2095
+ the happy one: if the switch fails after the stash, the work goes back before
2096
+ the error is reported, because leaving someone's edits in a stash they never
2097
+ asked for is worse than the failed switch. A pop that conflicts is not hidden
2098
+ either — git keeps the stash entry in that case, and the caller says so. */
2099
+ async function switchBranch(input, name) {
2100
+ const args = argsFor(input)
2101
+ const requested = repoFrom(input, null)
2102
+ const finish = function (result, extra) {
2103
+ invalidateRepo(requested !== undefined ? requested : result.cwd)
2104
+ const out = extra == null ? {} : extra
2105
+ out.ok = result.exitCode === 0
2106
+ out.repo = result.cwd
2107
+ out.stdout = result.stdout
2108
+ out.stderr = result.stderr
2109
+ out.exitCode = result.exitCode
2110
+ out.command = result.command
2111
+ out.sandboxDenied = result.sandboxDenied === true
2112
+ return out
2113
+ }
2114
+
2115
+ if (input == null || input.stash !== true) {
2116
+ const moved = await git(args, ['switch', name], null, {})
2117
+ return finish(moved, { stashed: false, dirty: 0, popConflict: false })
2118
+ }
2119
+
2120
+ /* --no-optional-locks is a top-level option, so it has to sit before the
2121
+ subcommand: git status rejects it in its own option list. Without it this
2122
+ read fights the user's own `git add` for .git/index.lock. */
2123
+ const before = await git(args, ['--no-optional-locks', 'status', '--porcelain=v1', '--untracked-files=normal'], null, {})
2124
+ let dirty = 0
2125
+ const lines = before.stdout.split('\n')
2126
+ for (let i = 0; i < lines.length; i += 1) if (lines[i].length > 0) dirty += 1
2127
+
2128
+ let stashed = false
2129
+ if (dirty > 0 && before.exitCode === 0) {
2130
+ const saved = await git(args, ['stash', 'push', '-u', '-m', 'dsh-git-idea: switch to ' + name], null, {})
2131
+ if (saved.exitCode !== 0) {
2132
+ return finish(saved, { stashed: false, dirty: dirty, popConflict: false, error: 'stash-failed' })
2133
+ }
2134
+ stashed = true
2135
+ }
2136
+
2137
+ const moved = await git(args, ['switch', name], null, {})
2138
+ if (moved.exitCode !== 0) {
2139
+ let restored = false
2140
+ let restoreError = ''
2141
+ if (stashed) {
2142
+ const back = await git(args, ['stash', 'pop'], null, {})
2143
+ restored = back.exitCode === 0
2144
+ restoreError = restored ? '' : back.stderr
2145
+ }
2146
+ return finish(moved, {
2147
+ stashed: stashed, restored: restored, restoreError: restoreError,
2148
+ dirty: dirty, popConflict: false, error: 'switch-failed',
2149
+ })
2150
+ }
2151
+
2152
+ let popConflict = false
2153
+ let popStdout = ''
2154
+ let popStderr = ''
2155
+ if (stashed) {
2156
+ const popped = await git(args, ['stash', 'pop'], null, {})
2157
+ popConflict = popped.exitCode !== 0
2158
+ /* Which stream git chooses is not stable — a conflicting pop narrates the
2159
+ merge on stdout and the failure on stderr — so both travel and the caller
2160
+ shows whichever has something in it. */
2161
+ popStdout = popped.stdout
2162
+ popStderr = popped.stderr
2163
+ }
2164
+ return finish(moved, {
2165
+ stashed: stashed, dirty: dirty, popConflict: popConflict,
2166
+ popStdout: popStdout, popStderr: popStderr,
2167
+ })
2168
+ }
2169
+
2170
+ /* ─────────────── one commit, one file, one directory, and the one mutation ─────────────── */
2171
+
2172
+ async function readCommitDetail(input) {
2173
+ const hash = input != null && isStr(input.hash) ? input.hash.trim() : ''
2174
+ if (hash.length === 0) return { ok: false, error: 'hash is required' }
2175
+ const args = argsFor(input)
2176
+ const meta = await git(args, ['-c', 'core.quotePath=false', 'show', '-s',
2177
+ '--format=%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s%x1f%b', hash], null, {})
2178
+ if (meta.exitCode !== 0) return { ok: false, error: 'commit-not-found', stderr: meta.stderr }
2179
+ const fields = meta.stdout.split('\u001f')
2180
+
2181
+ const files = []
2182
+ const named = await git(args, ['-c', 'core.quotePath=false', 'show', '--name-status', '-z', '-M', '--first-parent', '--format=', hash], null, { maxBytes: 800000 })
2183
+ if (named.exitCode === 0) {
2184
+ const parts = named.stdout.split('\u0000')
2185
+ let index = 0
2186
+ while (index < parts.length) {
2187
+ const status = parts[index]
2188
+ if (status === undefined || status.length === 0) { index += 1; continue }
2189
+ const head = status.charAt(0)
2190
+ if (head === 'R' || head === 'C') {
2191
+ const from = field(parts, index + 1)
2192
+ const to = field(parts, index + 2)
2193
+ files.push({ status: status, path: to, from: from })
2194
+ index += 3
2195
+ } else {
2196
+ const path = field(parts, index + 1)
2197
+ files.push({ status: status, path: path, from: null })
2198
+ index += 2
2199
+ }
2200
+ }
2201
+ }
2202
+
2203
+ const branches = []
2204
+ const contains = await git(args, ['-c', 'core.quotePath=false', 'branch', '-a', '--contains', hash, '--format=%(refname:short)'], null, {})
2205
+ if (contains.exitCode === 0) {
2206
+ const rows = contains.stdout.split('\n')
2207
+ for (let i = 0; i < rows.length; i += 1) if (rows[i].length > 0) branches.push(rows[i])
2208
+ }
2209
+
2210
+ return {
2211
+ ok: true,
2212
+ hash: fields[0] === undefined ? hash : fields[0],
2213
+ short: field(fields, 1),
2214
+ author: field(fields, 2),
2215
+ email: field(fields, 3),
2216
+ date: field(fields, 4),
2217
+ subject: field(fields, 5),
2218
+ body: field(fields, 6).replace(/\s+$/, ''),
2219
+ files: files,
2220
+ branches: branches,
2221
+ }
2222
+ }
2223
+
2224
+ /* A commit is immutable, so its detail is held until the repository is mutated
2225
+ (which cannot rewrite an existing hash, so this only frees memory). */
2226
+ async function commitDetailSnapshot(input) {
2227
+ const hash = input != null && isStr(input.hash) ? input.hash.trim() : ''
2228
+ if (hash.length === 0) return { ok: false, error: 'hash is required' }
2229
+ const repo = repoFrom(input, null)
2230
+ if (repo === undefined) return await readCommitDetail(input)
2231
+ return await cached(repo, 'detail|' + hash, function () { return readCommitDetail(input) })
2232
+ }
2233
+
2234
+ /* ── one file's change ──
2235
+
2236
+ The panel could say *which* files changed and nothing else. The changes tree
2237
+ and the file list under a commit both ended on a dead row: there was no read
2238
+ that returned a patch, so a file could be reported as modified and never shown
2239
+ as modified. This is that read.
2240
+
2241
+ Four modes, because that is what the two lists can be looking at: the index
2242
+ against HEAD (`staged`), the working tree against the index (`worktree`), one
2243
+ commit (`commit`, with the old path of a rename travelling along — with only
2244
+ the new path as a pathspec git reports the file as newly added instead), and a
2245
+ file git has never seen (`untracked`, the one case with no HEAD side at all,
2246
+ which needs `--no-index`).
2247
+
2248
+ Nothing is cached. The text is live, and the reader looking at it is the one
2249
+ who just edited the file. `--no-color` because there is no terminal to read
2250
+ colour, `--no-ext-diff` because a configured diff driver is not a viewer, and
2251
+ `core.quotePath=false` so a non-ASCII path arrives as itself.
2252
+
2253
+ `--no-optional-locks` is the same rule every read here follows, and here it is
2254
+ load-bearing rather than decorative: measured on this box after `touch f` made
2255
+ the cached stat stale, a plain `git diff` rewrote `.git/index` in 2 of 20 runs
2256
+ while the flagged one did 0 of 20 (`git status` writes 20 of 20) — a read that
2257
+ may refresh the index may also take the lock, occasionally being exactly the
2258
+ case that hurts. */
2259
+ const DIFF_MODES = ['worktree', 'staged', 'commit', 'untracked']
2260
+ const DIFF_LINES_MAX = 6000
2261
+ const DIFF_CHARS_MAX = 400000
2262
+ const DIFF_SHA = /^[0-9a-f]{7,40}$/i
2263
+
2264
+ function diffArgv(mode, paths, ref) {
2265
+ const common = ['--no-optional-locks', '-c', 'core.quotePath=false', 'diff', '--no-color', '--no-ext-diff']
2266
+ if (mode === 'commit') {
2267
+ /* `-m --first-parent` is what makes a merge commit readable: without it git
2268
+ prints the combined diff, which for a clean merge is empty — the file list
2269
+ would name a change and the patch would say there is none. */
2270
+ return ['--no-optional-locks', '-c', 'core.quotePath=false', 'show', '--no-color', '--no-ext-diff',
2271
+ '--format=', '--patch', '-m', '--first-parent', ref, '--'].concat(paths)
2272
+ }
2273
+ if (mode === 'untracked') return common.concat(['--no-index', '--', '/dev/null', paths[0]])
2274
+ if (mode === 'staged') return common.concat(['--cached', '--']).concat(paths)
2275
+ return common.concat(['--']).concat(paths)
2276
+ }
2277
+
2278
+ /* The two numbers IDEA puts in the corner of a file row. `---`/`+++` are the
2279
+ file headers rather than a removed and an added line. */
2280
+ function patchCounts(patch) {
2281
+ let added = 0
2282
+ let removed = 0
2283
+ const lines = patch.split('\n')
2284
+ for (let i = 0; i < lines.length; i += 1) {
2285
+ const head = lines[i].charAt(0)
2286
+ if (head === '+') { if (lines[i].indexOf('+++') !== 0) added += 1; continue }
2287
+ if (head === '-') { if (lines[i].indexOf('---') !== 0) removed += 1; continue }
2288
+ }
2289
+ return { added: added, removed: removed }
2290
+ }
2291
+
2292
+ /* A patch that is not text is not shown as text. A new binary file is printed by
2293
+ git as its own bytes when the file happens to contain no NUL — measured here —
2294
+ and a byte string with a NUL in it cannot survive being handed to a renderer
2295
+ at all, so both are reported as "binary" with no body. */
2296
+ function patchLooksBinary(patch) {
2297
+ if (patch.indexOf('\u0000') >= 0) return true
2298
+ if (/^Binary files /m.test(patch)) return true
2299
+ if (/^GIT binary patch/m.test(patch)) return true
2300
+ return patchHasControlBytes(patch)
2301
+ }
2302
+
2303
+ /* git decides "binary" from the first 8000 bytes, so a NUL-free blob is printed
2304
+ as if it were text: measured here, 400 random bytes came back as one 524-byte
2305
+ line of noise. Text does not carry C0 control characters, and a handful is
2306
+ enough to tell. */
2307
+ function patchHasControlBytes(patch) {
2308
+ let seen = 0
2309
+ for (let i = 0; i < patch.length; i += 1) {
2310
+ const code = patch.charCodeAt(i)
2311
+ if (code === 9 || code === 10 || code === 13) continue
2312
+ if (code < 32 || code === 127) {
2313
+ seen += 1
2314
+ if (seen > 8) return true
2315
+ }
2316
+ }
2317
+ return false
2318
+ }
2319
+
2320
+ async function readFileDiff(input) {
2321
+ const mode = input != null && isStr(input.mode) ? input.mode.trim() : ''
2322
+ if (DIFF_MODES.indexOf(mode) < 0) {
2323
+ return { ok: false, error: 'unknown-mode', exitCode: null, stderr: 'mode must be one of ' + DIFF_MODES.join(', ') }
2324
+ }
2325
+ const path = input != null && isStr(input.path) ? input.path : ''
2326
+ const bad = repoRelativePath(path)
2327
+ if (bad.length > 0) return { ok: false, error: 'invalid-path', exitCode: null, stderr: bad }
2328
+
2329
+ const from = input != null && isStr(input.from) ? input.from : ''
2330
+ const ref = input != null && isStr(input.ref) ? input.ref.trim() : ''
2331
+ if (mode === 'commit' && !DIFF_SHA.test(ref)) {
2332
+ return { ok: false, error: 'invalid-ref', exitCode: null, stderr: 'a commit hash is required' }
2333
+ }
2334
+ const paths = from.length > 0 && from !== path ? [from, path] : [path]
2335
+
2336
+ const result = await git(argsFor(input), diffArgv(mode, paths, ref), null, { maxBytes: 1200000 })
2337
+ /* `git diff --no-index` says "these differ" with exit code 1 and a perfectly
2338
+ good patch on stdout; every other mode says it with exit code 0. */
2339
+ const produced = result.exitCode === 0 || (mode === 'untracked' && result.exitCode === 1 && result.stdout.length > 0)
2340
+ if (!produced) {
2341
+ return { ok: false, error: 'diff-failed', exitCode: result.exitCode, stderr: result.stderr, mode: mode, path: path }
2342
+ }
2343
+
2344
+ const binary = patchLooksBinary(result.stdout)
2345
+ const counts = binary ? { added: 0, removed: 0 } : patchCounts(result.stdout)
2346
+ let patch = binary ? '' : result.stdout
2347
+ let truncated = result.truncated === true
2348
+ const lines = patch.split('\n')
2349
+ if (lines.length > DIFF_LINES_MAX) {
2350
+ patch = lines.slice(0, DIFF_LINES_MAX).join('\n')
2351
+ truncated = true
2352
+ }
2353
+ if (patch.length > DIFF_CHARS_MAX) {
2354
+ patch = patch.slice(0, DIFF_CHARS_MAX)
2355
+ truncated = true
2356
+ }
2357
+ return {
2358
+ ok: true, mode: mode, path: path, from: from, ref: ref,
2359
+ text: patch, added: counts.added, removed: counts.removed,
2360
+ binary: binary, truncated: truncated, empty: patch.length === 0,
2361
+ exitCode: result.exitCode,
2362
+ }
2363
+ }
2364
+
2365
+ /* ── what is inside an untracked directory ──
2366
+
2367
+ git reports an untracked directory as one entry ending in "/" and says nothing
2368
+ about what is in it, which is exactly why the panel could not show those files:
2369
+ it had nothing to show. Ticking the row needs no answer (`git add -- dir`
2370
+ takes the whole thing); opening it is a second, deliberate act, and that is the
2371
+ right moment to pay — `-uall` on a repository with one large untracked tree is
2372
+ the cost git's own collapsing exists to avoid.
2373
+
2374
+ `ls-files --others --exclude-standard` lists the files `git add <dir>` would
2375
+ take, honours .gitignore the same way, and is a read: `--no-optional-locks`,
2376
+ no index write. Not cached — the directory is the thing most likely to be
2377
+ changing while the reader looks at it. */
2378
+ const UNTRACKED_FILES_MAX = 2000
2379
+
2380
+ async function readUntrackedTree(input) {
2381
+ const raw = input != null && isStr(input.dir) ? input.dir : ''
2382
+ const dir = raw.replace(/\/+$/, '')
2383
+ const bad = repoRelativePath(dir)
2384
+ if (bad.length > 0) return { ok: false, error: 'invalid-path', stderr: bad }
2385
+ const result = await git(argsFor(input), ['--no-optional-locks', '-c', 'core.quotePath=false',
2386
+ 'ls-files', '--others', '--exclude-standard', '-z', '--', dir], null, { maxBytes: 800000 })
2387
+ if (result.exitCode !== 0) {
2388
+ return { ok: false, error: 'ls-files-failed', exitCode: result.exitCode, stderr: result.stderr, dir: dir }
2389
+ }
2390
+ const files = []
2391
+ const parts = result.stdout.split('\u0000')
2392
+ for (let i = 0; i < parts.length; i += 1) {
2393
+ if (parts[i].length > 0 && files.length < UNTRACKED_FILES_MAX) files.push(parts[i])
2394
+ }
2395
+ return {
2396
+ ok: true, dir: dir, files: files,
2397
+ truncated: result.truncated === true || parts.length > UNTRACKED_FILES_MAX + 1,
2398
+ }
2399
+ }
2400
+
2401
+ async function panelMutate(input, argv, options) {
2402
+ const opts = options == null ? {} : options
2403
+ const requested = repoFrom(input, null)
2404
+ const runner = opts.net === true ? gitNet : git
2405
+ const result = await runner(argsFor(input), argv, null, opts.spawn == null ? {} : opts.spawn)
2406
+ /* Unconditionally, not only on success: a conflicting cherry-pick, merge or
2407
+ revert changes the index and the working tree and then exits non-zero, so
2408
+ gating on exitCode === 0 would leave the panel showing the pre-conflict
2409
+ state and hide the very banner that gets the user out of it. */
2410
+ invalidateRepo(requested !== undefined ? requested : result.cwd)
2411
+ return {
2412
+ ok: result.exitCode === 0, repo: result.cwd, stdout: result.stdout,
2413
+ stderr: result.stderr, exitCode: result.exitCode, command: result.command,
2414
+ /* Says outright that the file sandbox refused the write, so the reader is not
2415
+ left reading git's "Permission denied" as a problem with their repository. */
2416
+ sandboxDenied: result.sandboxDenied === true,
2417
+ }
2418
+ }
2419
+
2420
+ function panelPaths(input) {
2421
+ return input != null && Array.isArray(input.paths) ? input.paths.filter(isStr) : []
2422
+ }
2423
+
2424
+ /* ── plugin-side configuration ──
2425
+
2426
+ Split from the browser-local presentation preferences on purpose. "Which
2427
+ branch should a new repository start on" is a property of this plugin, not of
2428
+ whoever happens to be looking at it, so it belongs beside the deployment's
2429
+ own settings rather than in one browser's localStorage. When this becomes an
2430
+ ordinary plugin this file is exactly what its config section would own. */
2431
+
2432
+ let configPathCache
2433
+ let configCache = null
2434
+
2435
+ async function configPath() {
2436
+ if (configPathCache !== undefined) return configPathCache
2437
+ const probe = await invoke('printf %s "${DSH_HOME:-$HOME/.dsh}"', {}, null, { timeoutMs: 10000 })
2438
+ const home = probe.exitCode === 0 ? probe.stdout.trim() : ''
2439
+ configPathCache = home.length > 0 ? home + '/dsh-git-idea.json' : null
2440
+ return configPathCache
2441
+ }
2442
+
2443
+ function normalizeConfig(raw) {
2444
+ const out = { initBranch: 'main', cherryPickRecord: false }
2445
+ if (raw == null || typeof raw !== 'object') return out
2446
+ if (isStr(raw.initBranch)) out.initBranch = raw.initBranch.trim().slice(0, 120)
2447
+ out.cherryPickRecord = raw.cherryPickRecord === true
2448
+ return out
2449
+ }
2450
+
2451
+ async function readConfigFile() {
2452
+ if (configCache !== null) return configCache
2453
+ const path = await configPath()
2454
+ const fsService = ctx.get('fs')
2455
+ if (path === null || fsService === undefined) { configCache = normalizeConfig(null); return configCache }
2456
+ try {
2457
+ const target = await fsService.resolve(path)
2458
+ const info = await fsService.stat(target)
2459
+ if (info === undefined) { configCache = normalizeConfig(null); return configCache }
2460
+ configCache = normalizeConfig(JSON.parse(await fsService.readText(target)))
2461
+ } catch (error) {
2462
+ console.error('dsh-git-idea: could not read the plugin config', String(error))
2463
+ configCache = normalizeConfig(null)
2464
+ }
2465
+ return configCache
2466
+ }
2467
+
2468
+ async function writeConfigFile(raw) {
2469
+ const path = await configPath()
2470
+ if (path === null) return { ok: false, error: '无法确定配置目录' }
2471
+ const fsService = ctx.get('fs')
2472
+ if (fsService === undefined) return { ok: false, error: '文件系统服务不可用' }
2473
+ const next = normalizeConfig(raw)
2474
+ try {
2475
+ const target = await fsService.resolve(path)
2476
+ await fsService.writeText(target, JSON.stringify(next, null, 2) + '\n')
2477
+ configCache = next
2478
+ return { ok: true, path: path, config: next }
2479
+ } catch (error) {
2480
+ const detail = error != null && error.message !== undefined ? String(error.message) : String(error)
2481
+ return { ok: false, error: detail, path: path }
2482
+ }
2483
+ }
2484
+
2485
+ async function initSnapshot(input) {
2486
+ const target = repoFrom(input, null)
2487
+ if (target === undefined) return { ok: false, error: 'no-path', stderr: '无法确定要初始化的目录', repo: null }
2488
+ const kind = await pathKind(input, target)
2489
+ if (kind !== 'dir') {
2490
+ return {
2491
+ ok: false, error: 'bad-path', repo: target,
2492
+ stderr: kind === 'file' ? '目标是一个文件,不是目录' : '目标目录不存在,无法在此初始化仓库',
2493
+ }
2494
+ }
2495
+ const branch = input != null && isStr(input.branch) ? input.branch.trim() : ''
2496
+ /* Through `argsAt`, so the session id survives: `git init` is a write, and a
2497
+ request that loses its session runs under the deployment's default sandbox
2498
+ policy rather than this reader's — which is a "Permission denied" on a
2499
+ directory the reader can write to perfectly well. */
2500
+ const args = argsAt(input, target)
2501
+ if (branch.length > 0) return await panelMutate(args, ['init', '-b', branch])
2502
+ return await panelMutate(args, ['init'])
2503
+ }
2504
+
2505
+ /* Every request the Client can make, in one table. Each handler is registered
2506
+ through `ctx.effect` so it belongs to this fiber: stopping or updating the
2507
+ Package removes all of them, which is what makes the bridge's reload safe. */
2508
+ function onRpc(name, handler) {
2509
+ ctx.effect(function () { return harness.handle(name, handler) }, 'dsh-git-idea rpc ' + name)
2510
+ }
2511
+
2512
+ onRpc('git/panel', function (input) { return panelSnapshot(input) })
2513
+
2514
+ onRpc('git/init', function (input) { return initSnapshot(input) })
2515
+
2516
+ /* The client's refresh button must be able to force a re-read; without this it
2517
+ would only repaint whatever the read cache already held. */
2518
+ onRpc('git/flush', function (input) {
2519
+ invalidateRepo(repoFrom(input, null))
2520
+ return { ok: true }
2521
+ })
2522
+
2523
+ onRpc('git/config', function () {
2524
+ return configPath().then(function (path) {
2525
+ return readConfigFile().then(function (config) {
2526
+ return { ok: true, path: path, config: config }
2527
+ })
2528
+ })
2529
+ })
2530
+
2531
+ onRpc('git/config-save', function (input) {
2532
+ return writeConfigFile(input != null ? input.config : null)
2533
+ })
2534
+
2535
+ /* Never cached: its whole purpose is to observe change. `paths` narrows the
2536
+ working-tree half of the signature to what is on screen — see watchCommand for
2537
+ what a whole-tree status costs on a slow mount. */
2538
+ onRpc('git/watch', function (input) {
2539
+ const target = repoFrom(input, null)
2540
+ if (target === undefined) return { ok: false, repo: null, sig: '' }
2541
+ const deep = input != null && input.deep === true
2542
+ const paths = deep ? readPaths(input) : []
2543
+ return probeShell(input, watchCommand(target, deep, paths)).then(function (probe) {
2544
+ return { ok: true, repo: target, sig: probe.stdout, paths: paths }
2545
+ })
2546
+ })
2547
+
2548
+ onRpc('git/graph', function (input) { return graphSnapshot(input) })
2549
+
2550
+ onRpc('git/authors', function (input) { return authorsSnapshot(input) })
2551
+
2552
+ onRpc('git/refs', function (input) { return refsSnapshot(input) })
2553
+
2554
+ onRpc('git/branches', function (input) { return branchesSnapshot(input) })
2555
+
2556
+ onRpc('git/commit-detail', function (input) { return commitDetailSnapshot(input) })
2557
+
2558
+ /* The one read the panel asks for by path rather than by repository: the patch
2559
+ behind a row in the changes tree or in a commit's file list. Never cached —
2560
+ it is the live text of a file the reader is looking at. */
2561
+ onRpc('git/diff', function (input) { return readFileDiff(input) })
2562
+
2563
+ /* git collapses an untracked directory into a single entry; this is what is
2564
+ inside it, asked for only when the reader opens that row. */
2565
+ onRpc('git/untracked', function (input) { return readUntrackedTree(input) })
2566
+
2567
+ onRpc('git/stage', function (input) {
2568
+ const paths = panelPaths(input)
2569
+ if (paths.length === 0) return { ok: false, error: 'no paths given' }
2570
+ return panelMutate(input, ['add', '--'].concat(paths))
2571
+ })
2572
+
2573
+ onRpc('git/unstage', function (input) {
2574
+ const paths = panelPaths(input)
2575
+ if (paths.length === 0) return { ok: false, error: 'no paths given' }
2576
+ return panelMutate(input, ['restore', '--staged', '--'].concat(paths))
2577
+ })
2578
+
2579
+ onRpc('git/commit', function (input) {
2580
+ const message = input != null && isStr(input.message) ? input.message.trim() : ''
2581
+ if (message.length === 0) return { ok: false, error: 'a commit message is required' }
2582
+ if (input != null && input.stageAll === true) {
2583
+ return panelMutate(input, ['add', '-A']).then(function (staged) {
2584
+ if (staged.ok !== true) return staged
2585
+ return panelMutate(input, ['commit', '-m', message])
2586
+ })
2587
+ }
2588
+ return panelMutate(input, ['commit', '-m', message])
2589
+ })
2590
+
2591
+ onRpc('git/checkout', async function (input) {
2592
+ const name = input != null && isStr(input.name) ? input.name.trim() : ''
2593
+ if (name.length === 0) return { ok: false, error: 'a branch name is required' }
2594
+ /* `-` is git's own shorthand for the previous branch; resolve it here so the
2595
+ caller never has to know that the switcher's "previous" row and this
2596
+ argument are the same idea. */
2597
+ let target = name
2598
+ if (name === '-') {
2599
+ target = await previousBranch(input)
2600
+ if (target.length === 0) return { ok: false, error: 'there is no previous branch to switch back to' }
2601
+ }
2602
+ return await switchBranch(input, target)
2603
+ })
2604
+
2605
+ const NET_SPAWN = { timeoutMs: 180000 }
2606
+
2607
+ onRpc('git/fetch', function (input) {
2608
+ return panelMutate(input, ['fetch', '--all', '--prune'], { net: true, spawn: NET_SPAWN })
2609
+ })
2610
+
2611
+ onRpc('git/pull', function (input) {
2612
+ return panelMutate(input, ['pull'], { net: true, spawn: NET_SPAWN })
2613
+ })
2614
+
2615
+ onRpc('git/push', function (input) {
2616
+ if (input != null && input.setUpstream === true) {
2617
+ const remote = input != null && isStr(input.remote) && input.remote.trim().length > 0 ? input.remote.trim() : ''
2618
+ const branch = input != null && isStr(input.branch) ? input.branch.trim() : ''
2619
+ if (remote.length === 0) return { ok: false, error: 'no remote is configured to push to' }
2620
+ if (branch.length === 0) return { ok: false, error: 'a branch is required to set an upstream' }
2621
+ return panelMutate(input, ['push', '-u', remote, branch], { net: true, spawn: NET_SPAWN })
2622
+ }
2623
+ return panelMutate(input, ['push'], { net: true, spawn: NET_SPAWN })
2624
+ })
2625
+
2626
+ /* cherry-pick, revert and merge share one entry point because they also share
2627
+ the way they stop half-done: continue, skip or abort has to be reachable or a
2628
+ conflicted panel would trap the user with no way back. */
2629
+ const SEQUENCER_OPS = ['cherry-pick', 'revert', 'merge']
2630
+
2631
+ onRpc('git/sequence', function (input) {
2632
+ const op = input != null && isStr(input.op) ? input.op : ''
2633
+ const action = input != null && isStr(input.action) ? input.action : ''
2634
+ const target = input != null && isStr(input.target) ? input.target.trim() : ''
2635
+ if (SEQUENCER_OPS.indexOf(op) < 0) return { ok: false, error: 'unknown operation ' + op }
2636
+
2637
+ if (op === 'merge') {
2638
+ if (action === 'start') {
2639
+ if (target.length === 0) return { ok: false, error: 'a branch or commit is required to merge' }
2640
+ return panelMutate(input, ['merge', '--no-edit', target])
2641
+ }
2642
+ if (action === 'continue') return panelMutate(input, ['commit', '--no-edit'])
2643
+ if (action === 'abort') return panelMutate(input, ['merge', '--abort'])
2644
+ return { ok: false, error: 'merge supports start, continue and abort' }
2645
+ }
2646
+
2647
+ if (action === 'start') {
2648
+ if (target.length === 0) return { ok: false, error: 'a commit is required' }
2649
+ if (op === 'revert') return panelMutate(input, ['revert', '--no-edit', target])
2650
+ if (input != null && input.record === true) return panelMutate(input, ['cherry-pick', '-x', target])
2651
+ return panelMutate(input, ['cherry-pick', target])
2652
+ }
2653
+ if (action === 'continue') return panelMutate(input, ['-c', 'core.editor=true', op, '--continue'])
2654
+ if (action === 'abort') return panelMutate(input, [op, '--abort'])
2655
+ if (action === 'skip') return panelMutate(input, [op, '--skip'])
2656
+ return { ok: false, error: op + ' does not support ' + action }
2657
+ })
2658
+
2659
+ onRpc('git/branch-create', function (input) {
2660
+ const name = input != null && isStr(input.name) ? input.name.trim() : ''
2661
+ if (name.length === 0) return { ok: false, error: 'a branch name is required' }
2662
+ const at = input != null && isStr(input.at) ? input.at.trim() : ''
2663
+ if (at.length > 0) return panelMutate(input, ['switch', '-c', name, at])
2664
+ return panelMutate(input, ['switch', '-c', name])
2665
+ })
2666
+
2667
+ onRpc('git/branch-delete', function (input) {
2668
+ const name = input != null && isStr(input.name) ? input.name.trim() : ''
2669
+ if (name.length === 0) return { ok: false, error: 'a branch name is required' }
2670
+ const force = input != null && input.force === true
2671
+ return panelMutate(input, ['branch', force ? '-D' : '-d', name])
2672
+ })
2673
+
2674
+ onRpc('git/tag', function (input) {
2675
+ const name = input != null && isStr(input.name) ? input.name.trim() : ''
2676
+ if (name.length === 0) return { ok: false, error: 'a tag name is required' }
2677
+ const at = input != null && isStr(input.at) ? input.at.trim() : ''
2678
+ if (at.length > 0) return panelMutate(input, ['tag', name, at])
2679
+ return panelMutate(input, ['tag', name])
2680
+ })
2681
+
2682
+ },
2683
+ }
2684
+ })()
2685
+
2686
+ /* The body's own apply, plus the one thing the bridge used to own: the
2687
+ transport to the browser half. \`webServer\` is optional at the type level but
2688
+ present in every web profile; without it the model tools still register and
2689
+ only the panel goes quiet. */
2690
+ export function apply(ctx, config) {
2691
+ ctx.inject(['webServer'], function (scope) {
2692
+ scope.effect(function () {
2693
+ return scope.webServer.register({ kind: 'exact', path: RPC_PATH, handler: rpcRoute })
2694
+ }, 'dsh-git-idea rpc route')
2695
+ })
2696
+ return plugin.apply(ctx, config)
2697
+ }
2698
+
2699
+ /* \`tools\` is the only hard dependency: every fragment reaches it through
2700
+ \`harness.registerTool\`. Every other service is read with \`ctx.get\` and guarded. */
2701
+ export const inject = ['tools']