dsh-git-idea 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +164 -62
  2. package/client/client.js +727 -120
  3. package/lib/index.js +634 -981
  4. package/package.json +3 -4
package/lib/index.js CHANGED
@@ -1,14 +1,14 @@
1
- /* GENERATED by build-package.mjs from 13 fragments under src/ — edit those, not this file. */
1
+ /* GENERATED by build-package.mjs from 12 fragments under src/ — edit those, not this file. */
2
2
  /* ── the real-package Host half ──
3
3
 
4
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'
5
+ that realm handed the plugin a \`harness\` and a façade \`ctx\`. A real package
6
+ gets neither, so this prelude supplies the one thing the body still asks of a
7
+ harness \`handle\`, which the RPC route below serves. (It used to supply
8
+ \`defineTool\` / \`registerTool\` too, for the model tools; the plugin ships
9
+ none, see the README's 「不注册工具」.) The body after it is the same text the
10
+ dynamic bridge loads — one body, two builds — and host-post.js exports the
11
+ plugin object. */
12
12
 
13
13
  /* The one route the browser half calls: same origin as the page, so no CORS.
14
14
  The same-origin check below is what keeps another page on loopback from
@@ -92,9 +92,9 @@ async function rpcRoute(request, response) {
92
92
  }
93
93
  }
94
94
 
95
+ /* The body registers RPC handlers here and nothing else: with no model tools
96
+ there is no tool registry to reach, and \`inject\` no longer names one. */
95
97
  const harness = {
96
- defineTool: function (options) { return defineTool(options) },
97
- registerTool: function (ctx, tool) { return ctx.tools.register(tool) },
98
98
  handle: function (method, handler) {
99
99
  rpcHandlers.set(method, handler)
100
100
  return function () { rpcHandlers.delete(method) }
@@ -109,7 +109,7 @@ return {
109
109
 
110
110
  const shell = ctx.get('shell')
111
111
  if (shell === undefined) {
112
- console.error('git plugin: the shell Service is unavailable; no tools registered')
112
+ console.error('git plugin: the shell Service is unavailable; no RPC registered')
113
113
  return
114
114
  }
115
115
 
@@ -213,12 +213,69 @@ async function invoke(command, args, exec, options) {
213
213
  }
214
214
  }
215
215
 
216
+ /* ── the one failure that is not about the repository ──
217
+
218
+ On a machine with no `git` on PATH every command below dies at the shell, and
219
+ its "command not found" arrives looking exactly like a repository git refused
220
+ to read: `git status` and `git rev-parse` both answer nothing, `$gd` comes out
221
+ empty, and a perfectly good repository is reported as "not a git repository" —
222
+ the one diagnosis the reader cannot act on, with the path field hidden besides.
223
+
224
+ So each git command opens with a question about the machine instead. `command
225
+ -v` is a shell builtin, so the check costs no process, and it asks about the
226
+ PATH *this* command will be resolved with — after `LC_ALL=C` and
227
+ `GIT_TERMINAL_PROMPT=0` have been put in front of it, neither of which changes
228
+ which git is found. The marker word and exit code 127 are what the Host reads;
229
+ bash's own sentence is never matched, because it is printed in whatever
230
+ language the machine happens to speak.
231
+
232
+ The panel scripts below are the one place this is not enough: they multiplex
233
+ several answers over stdout in a single process (`pathShell`), so they carry
234
+ the same check's answer as an output marker instead of an exit code —
235
+ `PANEL_NO_GIT`. Same question, same builtin, two transports. */
236
+ const GIT_MISSING_MARK = 'dsh-git-idea: no git on PATH'
237
+ /* `command -v <word>` answers for a bare name through PATH and for an absolute
238
+ path by checking it is executable, so one guard covers both "the git on PATH"
239
+ and "the git this reader configured". The command word is resolved once per
240
+ call (see `gitCmd` in 70-config.js) and cannot contain a newline. */
241
+ function gitGuard() {
242
+ return 'command -v ' + gitCmd() + ' >/dev/null 2>&1 || { printf ' + shq(GIT_MISSING_MARK + '\n') + ' >&2; exit 127; }\n'
243
+ }
244
+ const PANEL_NO_GIT = 'N:nogit'
245
+
246
+ /* ── the second failure that is not about the repository ──
247
+
248
+ `git commit` refuses to author a commit when it cannot work out who the
249
+ author is, and nothing about that is the repository's fault: the identity
250
+ lives in git's own configuration, and only the reader can choose a name and
251
+ an address. Read as plain stderr it arrives as eight lines of English
252
+ ending in `fatal: empty ident name`, which says what failed and not one word
253
+ about the two commands that fix it.
254
+
255
+ Recognised where it happens rather than matched out of that text: git
256
+ translates it, and it has several spellings (identity absent, an address but
257
+ no name, a guessed address that is not a full domain). The question asked
258
+ instead is `git var GIT_AUTHOR_IDENT` — the same lookup `git commit` makes
259
+ before it writes anything — and a non-zero exit answers "this commit is
260
+ going to be refused" in every locale. It is asked only *after* a commit has
261
+ already failed, so the path that works pays nothing for it. */
262
+ async function identityMissing(args) {
263
+ const asked = await gitC(args, ['var', 'GIT_AUTHOR_IDENT'], null, {})
264
+ return asked.exitCode !== 0
265
+ }
266
+
267
+ /* The same answer as a marker inside a panel script, where a non-zero exit code
268
+ cannot be the transport: `$gd` is already known to be non-empty by the time
269
+ this runs, so it is one git process, in the whole-tree read only. */
270
+ const PANEL_NO_IDENT = 'I:none'
271
+
216
272
  /* The three wrappers differ only in what they put in front of the command: the
217
273
  package prefix is the whole of the difference, so it is the only argument. */
218
274
  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(' ')
275
+ const result = await invoke(gitGuard() + prefix + gitCmd() + ' ' + argv.map(shq).join(' '), args, exec, options)
276
+ result.command = gitExe + ' ' + argv.join(' ')
221
277
  result.ok = result.exitCode === 0
278
+ result.noGit = result.exitCode === 127 && result.stderr.indexOf(GIT_MISSING_MARK) >= 0
222
279
  return result
223
280
  }
224
281
 
@@ -239,927 +296,33 @@ async function gitC(args, argv, exec, options) {
239
296
  return await shellGit('LC_ALL=C ', args, argv, exec, options)
240
297
  }
241
298
 
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) }
299
+ /* Every reply that carries a command's failure carries this with it, so that no
300
+ surface has to recognise a missing git by the words in stderr — see
301
+ `gitGuard`. One flag, read in one place per surface: the mutating commands
302
+ through `commandDetail`, the panel reads through their `reason`. */
303
+ function gitMissing(result) {
304
+ return result != null && result.noGit === true
270
305
  }
271
306
 
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
- }
307
+ /* ── HEAD 指着一个还没有提交的分支 ──
309
308
 
310
- /* ── a value is not an option ──
309
+ `git init` 出来的仓库就是这样(包括这个插件自己的引导页建出来的那个):HEAD
310
+ 里写着 `refs/heads/main`,而 `refs/heads` 是空的。它没有任何毛病,但所有读 *ref*
311
+ 的东西都拿不到答案 —— `for-each-ref` 一个分支都不列,`git log <branch>` 报
312
+ "unknown revision",而把「这次读失败了」当成「这不是一个仓库」的面板,就会对着
313
+ 它自己刚建出来的仓库说那句话。
311
314
 
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
315
+ 这里还剩一个问得出来的问题,而且 git 不用碰任何 ref 就能回答:HEAD 写的是哪个
316
+ 分支。只在 ref 表里一个当前分支都没有时才问一次 —— 普通仓库(`%(HEAD)` 已经标出
317
+ 来了)一次都不多花。真正的游离 HEAD 同样答不出来,而那正是调用方本来就会处理的
318
+ 情况。 */
319
+ async function headBranchWithoutCommit(args) {
320
+ const asked = await gitC(args, ['symbolic-ref', '--quiet', '--short', 'HEAD'], null, {})
321
+ if (asked.exitCode !== 0) return ''
322
+ const name = asked.stdout.trim()
323
+ return name.length > 0 ? name : ''
333
324
  }
334
325
 
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
326
  /* ─────────────── graph layout ─────────────── */
1164
327
 
1165
328
  function layoutGraph(commits, maxLanes) {
@@ -1361,6 +524,31 @@ function argsFor(input) {
1361
524
  return argsAt(input, repoFrom(input))
1362
525
  }
1363
526
 
527
+ /* ── a path that stays inside the repository ──
528
+
529
+ Every path the panel sends is one git itself listed, so it is relative to the
530
+ work-tree root. Three of the four diff reads pass it as a pathspec, which git
531
+ keeps inside the repository on its own. The untracked read cannot: it is
532
+ `git diff --no-index -- /dev/null <path>`, and that command reads whatever the
533
+ path names. Measured here, an absolute path came back with the contents of
534
+ /etc/hostname — a file no reader of a git panel asked for. So anything
535
+ absolute, or stepping up with "..", is refused rather than resolved.
536
+
537
+ A NUL byte is refused for a different reason: it survives as far as the shell
538
+ layer, where Node's own `spawn` rejects the argument outright (`ERR_INVALID_ARG_VALUE`,
539
+ measured), so the read would throw instead of answering. */
540
+ function repoRelativePath(path) {
541
+ if (!isStr(path) || path.length === 0) return 'path is required'
542
+ if (path.charAt(0) === '/' || path.charAt(0) === '\\') return 'path must be relative to the repository root'
543
+ if (path.length > 1 && path.charAt(1) === ':') return 'path must be relative to the repository root'
544
+ if (path.indexOf('\u0000') >= 0) return 'path may not contain a NUL byte'
545
+ const parts = path.split(/[\\/]/)
546
+ for (let i = 0; i < parts.length; i += 1) {
547
+ if (parts[i] === '..') return 'path may not step outside the repository'
548
+ }
549
+ return ''
550
+ }
551
+
1364
552
  /* ─────────────── per-repository read cache ───────────────
1365
553
 
1366
554
  Opening the panel used to cost nine child processes and nothing was reused,
@@ -1467,6 +655,15 @@ function sequencerShell() {
1467
655
  ].join('\n')
1468
656
  }
1469
657
 
658
+ /* Whether this machine can sign a commit at all, asked in the same process as
659
+ the rest of the whole-tree read — git's own lookup, and a marker instead of an
660
+ exit code for the same reason `PANEL_NO_GIT` is one (see 10-shell.js). It is
661
+ here so the commit pane can say it before the reader writes a message and gets
662
+ refused, rather than after. */
663
+ function identityShell(target) {
664
+ return " LC_ALL=C " + gitCmd() + " -C " + shq(target) + " var GIT_AUTHOR_IDENT >/dev/null 2>&1 || printf '" + PANEL_NO_IDENT + "\\n'"
665
+ }
666
+
1470
667
  /* The branch half of the identity read, in one git process instead of three.
1471
668
 
1472
669
  The branch name comes out of `$gd/HEAD` itself rather than out of
@@ -1495,7 +692,7 @@ function branchIdentityShell(target) {
1495
692
  ' fi',
1496
693
  ' if [ -n "$b" ]; then',
1497
694
  " 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)\"",
695
+ " printf 'U:%s\\n' \"$(LC_ALL=C " + gitCmd() + " -C " + shq(target) + " for-each-ref --format='%(upstream:short)%1f%(upstream:track)' \"refs/heads/$b\" 2>/dev/null)\"",
1499
696
  ' fi',
1500
697
  ].join('\n')
1501
698
  }
@@ -1510,11 +707,22 @@ function pathShell(target, middle) {
1510
707
  return [
1511
708
  'if [ -d ' + quoted + ' ]; then',
1512
709
  " printf 'K:dir\\n'",
710
+ /* Asked here, once, before anything that needs git runs. Whether this path
711
+ is a repository is answered by the filesystem above and stays true on a
712
+ machine with no git; what is not true is everything the middle would then
713
+ report, because an empty answer from a git that never ran is not "there is
714
+ nothing here". This is the multiplexed half of `gitGuard` — see there for
715
+ why the answer comes out as a marker instead of an exit code — and it
716
+ stops the script rather than letting the middle fail in a way that would
717
+ be read as git refusing the repository. `pathKind` is deliberately on the
718
+ other side of it: whether the path exists does not need git, so it still
719
+ answers here. */
720
+ ' if ! command -v ' + gitCmd() + ' >/dev/null 2>&1; then printf ' + shq(PANEL_NO_GIT + '\\n') + '; exit 0; fi',
1513
721
  /* Guarded by `repoHere` and not left to git: without the guard,
1514
722
  `git rev-parse` in a directory that is not a repository walks up and
1515
723
  answers for a parent one. */
1516
724
  ' if ' + repoHere(target) + '; then',
1517
- ' gd=$(git -C ' + quoted + ' rev-parse --absolute-git-dir 2>/dev/null)',
725
+ ' gd=$(' + gitCmd() + ' -C ' + quoted + ' rev-parse --absolute-git-dir 2>/dev/null)',
1518
726
  ' else',
1519
727
  " gd=''",
1520
728
  ' fi',
@@ -1585,7 +793,7 @@ function panelCommand(target, paths) {
1585
793
  const asked = paths == null ? [] : paths
1586
794
  return pathShell(target, [
1587
795
  ' 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=$?",
796
+ " out=$(" + gitCmd() + " -C " + quoted + " --no-optional-locks -c core.quotePath=false status --porcelain=v2 --branch --untracked-files=normal" + pathspecSuffix(asked) + " 2>&1); rc=$?",
1589
797
  ' else',
1590
798
  " out='fatal: not a git repository'; rc=1",
1591
799
  ' fi',
@@ -1593,6 +801,7 @@ function panelCommand(target, paths) {
1593
801
  " printf 'RC:%s\n' \"$rc\"",
1594
802
  ' if [ -n "$gd" ]; then',
1595
803
  sequencerShell(),
804
+ identityShell(target),
1596
805
  ' fi',
1597
806
  ].join('\n'))
1598
807
  }
@@ -1612,8 +821,9 @@ function panelCommand(target, paths) {
1612
821
  the reported status is identical either way.
1613
822
 
1614
823
  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
824
+ panel read missed it for a while (so did the `git_status` tool, while the
825
+ plugin still had one) and was caught by a case of exactly this — a fetch that
826
+ triggered auto-gc was blamed first, but
1617
827
  background gc never touches index.lock (it runs pack-objects --indexed-objects,
1618
828
  which only reads the index). Whoever writes .git/index is the suspect, and a
1619
829
  `git status` writes it. `test/gp34a` now holds both the rule and the probe. */
@@ -1633,7 +843,7 @@ function watchCommand(target, deep, paths) {
1633
843
  const out = [
1634
844
  "st() { stat -c '%Y:%s' \"$1\" 2>/dev/null || stat -f '%m:%z' \"$1\" 2>/dev/null; }",
1635
845
  'if ' + repoHere(target) + '; then',
1636
- ' gd=$(git -C ' + quoted + ' rev-parse --absolute-git-dir 2>/dev/null)',
846
+ ' gd=$(' + gitCmd() + ' -C ' + quoted + ' rev-parse --absolute-git-dir 2>/dev/null)',
1637
847
  'else',
1638
848
  " gd=''",
1639
849
  'fi',
@@ -1647,11 +857,11 @@ function watchCommand(target, deep, paths) {
1647
857
  it. The same command over the paths the tree was showing is 0.5s. A file
1648
858
  that was clean and is now modified is the one thing this cannot see; the
1649
859
  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')
860
+ out.push('if [ -n "$gd" ]; then ' + gitCmd() + ' -C ' + quoted + ' --no-optional-locks -c core.quotePath=false status --porcelain=v2 --branch --untracked-files=normal' + pathspecSuffix(asked) + ' 2>&1; fi')
1651
861
  }
1652
862
  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") + "\"",
863
+ "printf 'F:%s\\n' \"" + whenRepo(gitCmd() + " -C " + quoted + " for-each-ref --format='%(refname):%(objectname)' refs/heads refs/remotes 2>/dev/null") + "\"",
864
+ "printf 'H:%s\\n' \"" + whenRepo(gitCmd() + " -C " + quoted + " rev-parse -q --verify HEAD 2>/dev/null") + "\"",
1655
865
  "printf 'I:%s\\n' \"$(st \"$gd/index\")\"",
1656
866
  /* The HEAD *file*, not just its stamp. Two branches can point at the same
1657
867
  commit — `git switch -c` always does, and so does any pair left level by a
@@ -1668,6 +878,69 @@ function watchCommand(target, deep, paths) {
1668
878
 
1669
879
  /* ─────────────── the panel's own read, and the graph ─────────────── */
1670
880
 
881
+ /* ── `git status --porcelain=v2`, and the words for its two letters ──
882
+
883
+ The panel's full read is the only caller: the cheap identity read that the
884
+ chip uses asks three questions and never looks at the working tree.
885
+ `STATUS_LABELS` has one reader — `parseStatusV2` puts those words into each
886
+ entry's `label` — so it travels with the parser rather than on its own.
887
+ */
888
+ const STATUS_LABELS = {
889
+ M: 'modified', A: 'added', D: 'deleted', R: 'renamed', C: 'copied',
890
+ T: 'typechange', U: 'unmerged', '.': 'unchanged',
891
+ }
892
+
893
+ function statusLabel(code) {
894
+ return STATUS_LABELS[code] === undefined ? code : STATUS_LABELS[code]
895
+ }
896
+
897
+ function parseStatusV2(stdout) {
898
+ const parsed = {
899
+ branch: null, detached: false, upstream: null, ahead: 0, behind: 0,
900
+ staged: [], unstaged: [], untracked: [], unmerged: [],
901
+ }
902
+ const lines = stdout.split('\n')
903
+ for (let i = 0; i < lines.length; i += 1) {
904
+ const line = lines[i]
905
+ if (line.length === 0) continue
906
+ if (line.charAt(0) === '#') {
907
+ const head = line.slice(2)
908
+ const space = head.indexOf(' ')
909
+ const key = space < 0 ? head : head.slice(0, space)
910
+ const rest = space < 0 ? '' : head.slice(space + 1)
911
+ if (key === 'branch.head') {
912
+ if (rest === '(detached)') parsed.detached = true
913
+ else parsed.branch = rest
914
+ } else if (key === 'branch.upstream') {
915
+ parsed.upstream = rest
916
+ } else if (key === 'branch.ab') {
917
+ const parts = rest.split(' ')
918
+ if (parts.length === 2) {
919
+ parsed.ahead = parseInt(parts[0].slice(1), 10) || 0
920
+ parsed.behind = parseInt(parts[1].slice(1), 10) || 0
921
+ }
922
+ }
923
+ continue
924
+ }
925
+ const marker = line.charAt(0)
926
+ if (marker === '?') { parsed.untracked.push(line.slice(2)); continue }
927
+ if (marker === '!') continue
928
+ if (marker === '1' || marker === '2' || marker === 'u') {
929
+ const fields = line.split(' ')
930
+ const xy = fields[1] === undefined ? '..' : fields[1]
931
+ let path = ''
932
+ if (marker === '1') path = fields.slice(8).join(' ')
933
+ else if (marker === '2') path = fields.slice(9).join(' ').split('\t')[0]
934
+ else path = fields.slice(10).join(' ')
935
+ const entry = { path: path, code: xy, label: statusLabel(xy.charAt(0)) + '/' + statusLabel(xy.charAt(1)) }
936
+ if (marker === 'u') { parsed.unmerged.push(entry); continue }
937
+ if (xy.charAt(0) !== '.') parsed.staged.push(entry)
938
+ if (xy.charAt(1) !== '.') parsed.unstaged.push(entry)
939
+ }
940
+ }
941
+ return parsed
942
+ }
943
+
1671
944
  function missingPanel(target, reason) {
1672
945
  return {
1673
946
  ok: false, repo: target, error: 'not-a-repository', reason: reason,
@@ -1687,8 +960,10 @@ async function readPanelIdentity(input, target) {
1687
960
  let branch = null
1688
961
  let upstream = null
1689
962
  let track = ''
963
+ let noGit = false
1690
964
  for (let i = 1; i < lines.length; i += 1) {
1691
965
  const line = lines[i]
966
+ if (line === PANEL_NO_GIT) { noGit = true; continue }
1692
967
  if (line.indexOf('RC:') === 0) { exitCode = parseInt(line.slice(3), 10); continue }
1693
968
  if (line.indexOf('S:') === 0) { if (sequencer === null) sequencer = line.slice(2); continue }
1694
969
  if (line.indexOf('B:') === 0) { branch = line.slice(2); continue }
@@ -1700,6 +975,9 @@ async function readPanelIdentity(input, target) {
1700
975
  track = field(fields, 1)
1701
976
  }
1702
977
  }
978
+ /* Before the exit code, which the script never reached: "this machine has no
979
+ git" is not a repository that failed to read, and the reader can act on it. */
980
+ if (noGit) return missingPanel(target, 'no-git')
1703
981
  if (exitCode !== 0) {
1704
982
  const failed = missingPanel(target, 'not-a-repo')
1705
983
  failed.exitCode = exitCode
@@ -1729,15 +1007,20 @@ async function readPanel(input, target, paths) {
1729
1007
 
1730
1008
  let exitCode = null
1731
1009
  let sequencer = null
1010
+ let noGit = false
1011
+ let needsIdentity = false
1732
1012
  const body = []
1733
1013
  for (let i = 1; i < lines.length; i += 1) {
1734
1014
  const line = lines[i]
1015
+ if (line === PANEL_NO_GIT) { noGit = true; continue }
1016
+ if (line === PANEL_NO_IDENT) { needsIdentity = true; continue }
1735
1017
  if (line.indexOf('RC:') === 0) { exitCode = parseInt(line.slice(3), 10); continue }
1736
1018
  if (line.indexOf('S:') === 0) { if (sequencer === null) sequencer = line.slice(2); continue }
1737
1019
  body.push(line)
1738
1020
  }
1739
1021
  const output = body.join('\n')
1740
1022
 
1023
+ if (noGit) return missingPanel(target, 'no-git')
1741
1024
  if (exitCode !== 0) {
1742
1025
  const outsideRepo = output.indexOf('not a git repository') >= 0
1743
1026
  const failed = missingPanel(target, outsideRepo ? 'not-a-repo' : 'git-error')
@@ -1754,6 +1037,12 @@ async function readPanel(input, target, paths) {
1754
1037
  upstream: parsed.upstream, ahead: parsed.ahead, behind: parsed.behind,
1755
1038
  sequencer: sequencer,
1756
1039
  staged: parsed.staged, unstaged: parsed.unstaged, untracked: untracked, unmerged: parsed.unmerged,
1040
+ /* Always a boolean, never left out and never `undefined`: the bridge validates
1041
+ every field of a reply for "can this become lossless JSON", and a key whose
1042
+ value is `undefined` rejects the *whole* reply — the client would get an
1043
+ error instead of a snapshot. (Found live, not in a suite: the suites call
1044
+ these handlers directly, with no validation in between.) */
1045
+ needsIdentity: needsIdentity === true,
1757
1046
  }
1758
1047
  /* A pathspec answer is about those paths and nothing else. It says so, and it
1759
1048
  names them, so the client can fold it into the snapshot it already has
@@ -1825,7 +1114,25 @@ async function readGraph(input, repo) {
1825
1114
 
1826
1115
  const logged = await git(args, argv, null, {})
1827
1116
  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 }
1117
+ /* ── 一个提交都还没有的仓库 ──
1118
+ `git log <branch>` 在这里必然失败(那个 ref 还不存在),但那不是「读不动这个
1119
+ 仓库」:它是这个仓库的第一个状态,历史就是空的。判据是两句 git 自己的问话,
1120
+ 都不认措辞:`rev-parse --git-dir` 能答上来 = 这确实是个仓库(在仓库外它退出
1121
+ 128),而 `--verify HEAD` 答不上来 = HEAD 还没指向任何提交。
1122
+
1123
+ 注意别拿 `repoHere` 来判:它返回的是一段 shell 文本(给脚本拼命令用的),在
1124
+ JS 里永远为真 —— 第一版就是这么写的,负对照立刻把它抓出来了。 */
1125
+ const verifyHead = await git(args, ['rev-parse', '-q', '--verify', 'HEAD'], null, {})
1126
+ if (verifyHead.exitCode !== 0) {
1127
+ const gitDir = await git(args, ['rev-parse', '--git-dir'], null, {})
1128
+ if (gitDir.exitCode === 0) {
1129
+ return {
1130
+ ok: true, repo: logged.cwd, currentBranch: currentBranch, ref: ref, allRefs: allRefs,
1131
+ unborn: true, hasMore: false, maxCount: maxCount, commits: [], rows: [], lanes: 1,
1132
+ }
1133
+ }
1134
+ }
1135
+ return { ok: false, repo: repo === undefined ? null : repo, error: 'not-a-repository', stderr: logged.stderr, noGit: gitMissing(logged), currentBranch: currentBranch, ref: ref, commits: [], rows: [], lanes: 1 }
1829
1136
  }
1830
1137
  const parsed = parseCommitRecords(logged.stdout)
1831
1138
  const hasMore = parsed.length > maxCount
@@ -1897,7 +1204,7 @@ async function readAuthors(input, repo) {
1897
1204
  const args = argsFor(input)
1898
1205
  const listed = await git(args, ['--no-pager', 'shortlog', '-sne', '--all'], null, { maxBytes: 200000 })
1899
1206
  if (listed.exitCode !== 0) {
1900
- return { ok: false, repo: repo === undefined ? null : repo, error: 'not-a-repository', stderr: listed.stderr, authors: [] }
1207
+ return { ok: false, repo: repo === undefined ? null : repo, error: 'not-a-repository', stderr: listed.stderr, noGit: gitMissing(listed), authors: [] }
1901
1208
  }
1902
1209
  const authors = []
1903
1210
  const rows = listed.stdout.split('\n')
@@ -1938,7 +1245,7 @@ async function readRefs(input, repo) {
1938
1245
  '--format=%(refname)%1f%(refname:short)%1f%(HEAD)%1f%(objectname:short)%1f%(upstream:short)%1f%(upstream:track)%1f%(committerdate:unix)',
1939
1246
  'refs/heads', 'refs/remotes'], null, {})
1940
1247
  if (listed.exitCode !== 0) {
1941
- return { ok: false, repo: repo === undefined ? null : repo, error: 'not-a-repository', stderr: listed.stderr, current: [], local: [], remote: [] }
1248
+ return { ok: false, repo: repo === undefined ? null : repo, error: 'not-a-repository', stderr: listed.stderr, noGit: gitMissing(listed), current: [], local: [], remote: [] }
1942
1249
  }
1943
1250
  const local = []
1944
1251
  const current = []
@@ -1970,6 +1277,12 @@ async function readRefs(input, repo) {
1970
1277
  }
1971
1278
  const remote = []
1972
1279
  remoteMap.forEach(function (entries, name) { remote.push({ name: name, refs: entries }) })
1280
+ /* 一个 ref 都没标出当前分支,可能不是游离 HEAD,而是这个分支还没有提交 ——
1281
+ 那也要说出它的名字(见 `headBranchWithoutCommit`)。 */
1282
+ if (current.length === 0) {
1283
+ const unborn = await headBranchWithoutCommit(args)
1284
+ if (unborn.length > 0) current.push(unborn)
1285
+ }
1973
1286
  return { ok: true, repo: listed.cwd, current: current, local: local, remote: remote }
1974
1287
  }
1975
1288
 
@@ -2011,7 +1324,7 @@ async function readBranches(input, repo) {
2011
1324
  '--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
1325
  '--sort=-committerdate', 'refs/heads', 'refs/remotes'], null, {})
2013
1326
  if (listed.exitCode !== 0) {
2014
- return { ok: false, repo: repo === undefined ? null : repo, error: 'not-a-repository', stderr: listed.stderr, current: '', previous: '', branches: [], remotes: [] }
1327
+ return { ok: false, repo: repo === undefined ? null : repo, error: 'not-a-repository', stderr: listed.stderr, noGit: gitMissing(listed), current: '', previous: '', branches: [], remotes: [] }
2015
1328
  }
2016
1329
  const branches = []
2017
1330
  const remoteRows = []
@@ -2065,9 +1378,17 @@ async function readBranches(input, repo) {
2065
1378
  }
2066
1379
  const prev = await git(args, ['rev-parse', '--abbrev-ref', '@{-1}'], null, {})
2067
1380
  const previous = prev.exitCode === 0 ? prev.stdout.trim() : ''
1381
+ /* 当前分支还没有提交时,ref 表里没有它 —— 头上那行不能因此写成 HEAD(见
1382
+ `headBranchWithoutCommit`)。它不进 `branches`:那是一份 ref 清单,而它还不是
1383
+ 一个 ref,列进去会给人一个点得动的、其实不存在的东西。 */
1384
+ const unborn = current.length === 0 ? await headBranchWithoutCommit(args) : ''
2068
1385
  return {
2069
- ok: true, repo: listed.cwd, current: current,
1386
+ ok: true, repo: listed.cwd, current: current.length === 0 ? unborn : current,
2070
1387
  previous: previous === 'HEAD' || previous === current ? '' : previous,
1388
+ /* 说出来,切换器才有办法换一套说法:一个提交都没有的仓库里,`git stash`
1389
+ 直接失败("You do not have the initial commit yet"),所以那张卡片上「先暂存
1390
+ 再切(切完自动恢复)」这句承诺在这里兑现不了。 */
1391
+ unborn: unborn.length > 0,
2071
1392
  branches: branches, remotes: remotes,
2072
1393
  }
2073
1394
  }
@@ -2175,7 +1496,7 @@ async function readCommitDetail(input) {
2175
1496
  const args = argsFor(input)
2176
1497
  const meta = await git(args, ['-c', 'core.quotePath=false', 'show', '-s',
2177
1498
  '--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 }
1499
+ if (meta.exitCode !== 0) return { ok: false, error: 'commit-not-found', stderr: meta.stderr, noGit: gitMissing(meta) }
2179
1500
  const fields = meta.stdout.split('\u001f')
2180
1501
 
2181
1502
  const files = []
@@ -2338,7 +1659,7 @@ async function readFileDiff(input) {
2338
1659
  good patch on stdout; every other mode says it with exit code 0. */
2339
1660
  const produced = result.exitCode === 0 || (mode === 'untracked' && result.exitCode === 1 && result.stdout.length > 0)
2340
1661
  if (!produced) {
2341
- return { ok: false, error: 'diff-failed', exitCode: result.exitCode, stderr: result.stderr, mode: mode, path: path }
1662
+ return { ok: false, error: 'diff-failed', exitCode: result.exitCode, stderr: result.stderr, noGit: gitMissing(result), mode: mode, path: path }
2342
1663
  }
2343
1664
 
2344
1665
  const binary = patchLooksBinary(result.stdout)
@@ -2385,7 +1706,7 @@ async function readUntrackedTree(input) {
2385
1706
  const result = await git(argsFor(input), ['--no-optional-locks', '-c', 'core.quotePath=false',
2386
1707
  'ls-files', '--others', '--exclude-standard', '-z', '--', dir], null, { maxBytes: 800000 })
2387
1708
  if (result.exitCode !== 0) {
2388
- return { ok: false, error: 'ls-files-failed', exitCode: result.exitCode, stderr: result.stderr, dir: dir }
1709
+ return { ok: false, error: 'ls-files-failed', exitCode: result.exitCode, stderr: result.stderr, noGit: gitMissing(result), dir: dir }
2389
1710
  }
2390
1711
  const files = []
2391
1712
  const parts = result.stdout.split('\u0000')
@@ -2414,6 +1735,9 @@ async function panelMutate(input, argv, options) {
2414
1735
  /* Says outright that the file sandbox refused the write, so the reader is not
2415
1736
  left reading git's "Permission denied" as a problem with their repository. */
2416
1737
  sandboxDenied: result.sandboxDenied === true,
1738
+ /* And the other failure that is not about the repository: no git on this
1739
+ machine at all. Every mutating command and `git init` come through here. */
1740
+ noGit: gitMissing(result),
2417
1741
  }
2418
1742
  }
2419
1743
 
@@ -2441,40 +1765,82 @@ async function configPath() {
2441
1765
  }
2442
1766
 
2443
1767
  function normalizeConfig(raw) {
2444
- const out = { initBranch: 'main', cherryPickRecord: false }
1768
+ const out = {
1769
+ initBranch: 'main', cherryPickRecord: false,
1770
+ /* Which git to run. Empty means "the one on the deployment's PATH", which is
1771
+ what every command used before this key existed. */
1772
+ gitPath: '',
1773
+ /* The three choices the plugin itself makes about the network — each one is
1774
+ the argument this plugin passes, not a copy of a git setting: `git fetch`
1775
+ is `--all --prune` here, pull merges, and a push to a branch with no
1776
+ upstream asks first. git's own `push.default` / `pull.rebase` still apply
1777
+ underneath and are not overridden. */
1778
+ fetchPrune: true, pullRebase: false, pushSetUpstream: false,
1779
+ }
2445
1780
  if (raw == null || typeof raw !== 'object') return out
2446
1781
  if (isStr(raw.initBranch)) out.initBranch = raw.initBranch.trim().slice(0, 120)
2447
1782
  out.cherryPickRecord = raw.cherryPickRecord === true
1783
+ if (isStr(raw.gitPath)) out.gitPath = cleanGitPath(raw.gitPath)
1784
+ out.fetchPrune = raw.fetchPrune !== false
1785
+ out.pullRebase = raw.pullRebase === true
1786
+ out.pushSetUpstream = raw.pushSetUpstream === true
2448
1787
  return out
2449
1788
  }
2450
1789
 
1790
+ /* ── 这份配置文件走 shell,不走文件服务 ──
1791
+
1792
+ 它住在部署的配置目录里(`<DSH_HOME>/dsh-git-idea.json`),也就是**任何工作区之外**。
1793
+ 文件服务是按工作区发策略的,而这条路径不在任何一个工作区里:写它的时候拿到的是部署
1794
+ 默认那份策略(workspace-write),于是被拒绝 —— 而且拒得安静:面板里点了保存、磁盘上
1795
+ 一个字节没变、屏幕上什么也没说。这是真机上抓到的(改用 `git` 路径时)。
1796
+
1797
+ 所以它和这个插件改的所有东西走同一条路:`invoke` + 会话自己的沙箱策略(`sandboxFor`),
1798
+ 失败时答复里带着 sandboxDenied 和 git 那句话,界面上说得出为什么。读也一样走这条路,
1799
+ 免得读到一个地方、写到另一个地方。 */
1800
+
2451
1801
  async function readConfigFile() {
2452
1802
  if (configCache !== null) return configCache
2453
1803
  const path = await configPath()
2454
- const fsService = ctx.get('fs')
2455
- if (path === null || fsService === undefined) { configCache = normalizeConfig(null); return configCache }
1804
+ if (path === null) { configCache = applyConfig(normalizeConfig(null)); return configCache }
1805
+ /* `[ -f ]` 先说有没有这个文件:不存在不是错误,是「还没有配置过」。 */
1806
+ const probe = await invoke('[ -f ' + shq(path) + ' ] && cat ' + shq(path) + '\n', {}, null, { timeoutMs: 10000 })
1807
+ if (probe.exitCode !== 0) { configCache = applyConfig(normalizeConfig(null)); return configCache }
2456
1808
  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)))
1809
+ configCache = applyConfig(normalizeConfig(JSON.parse(probe.stdout)))
2461
1810
  } catch (error) {
2462
- console.error('dsh-git-idea: could not read the plugin config', String(error))
2463
- configCache = normalizeConfig(null)
1811
+ console.error('dsh-git-idea: could not parse the plugin config', String(error))
1812
+ configCache = applyConfig(normalizeConfig(null))
2464
1813
  }
2465
1814
  return configCache
2466
1815
  }
2467
1816
 
2468
- async function writeConfigFile(raw) {
1817
+ async function writeConfigFile(raw, args) {
2469
1818
  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: '文件系统服务不可用' }
1819
+ if (path === null) return { ok: false, error: '无法确定配置目录', stderr: '读不出部署的配置目录' }
2473
1820
  const next = normalizeConfig(raw)
2474
1821
  try {
2475
- const target = await fsService.resolve(path)
2476
- await fsService.writeText(target, JSON.stringify(next, null, 2) + '\n')
2477
- configCache = next
1822
+ /* `mkdir -p` 先来一次:`DSH_HOME` 可以指到一个还不存在的目录,而重定向不会替
1823
+ 你建目录。`printf %s` 而不是 heredoc —— 值里可能有引号、反斜杠、换行,`shq`
1824
+ 一个都不挑。 */
1825
+ const dir = path.slice(0, path.lastIndexOf('/'))
1826
+ const written = await invoke(
1827
+ 'mkdir -p ' + shq(dir) + ' && printf %s ' + shq(JSON.stringify(next, null, 2) + '\n') + ' > ' + shq(path) + '\n',
1828
+ args, null, { timeoutMs: 10000 })
1829
+ if (written.exitCode !== 0) {
1830
+ return {
1831
+ ok: false, path: path,
1832
+ error: written.sandboxDenied === true ? '文件沙箱不允许写这个配置文件' : '写不进这个配置文件',
1833
+ stderr: written.stderr, stdout: written.stdout,
1834
+ sandboxDenied: written.sandboxDenied === true,
1835
+ noGit: false,
1836
+ }
1837
+ }
1838
+ /* 换了一个 git,所有读的答案都可能跟着变 —— 可能从「读不动这个目录」变成读得动,
1839
+ 反过来也一样。缓存不作废的话,读者在设置页里把路径改对了,面板还在拿上一个二进
1840
+ 制留下的答案说话(这条是 fixture 抓出来的:改完之后同一棵树仍然回答旧的那一份)。 */
1841
+ const before = gitExe
1842
+ configCache = applyConfig(next)
1843
+ if (before !== gitExe) invalidateRepo(null)
2478
1844
  return { ok: true, path: path, config: next }
2479
1845
  } catch (error) {
2480
1846
  const detail = error != null && error.message !== undefined ? String(error.message) : String(error)
@@ -2502,11 +1868,252 @@ async function initSnapshot(input) {
2502
1868
  return await panelMutate(args, ['init'])
2503
1869
  }
2504
1870
 
1871
+ /* ─────────────── which git this machine runs ───────────────
1872
+
1873
+ Every command this plugin runs starts with one word: `git`. That word comes
1874
+ from the deployment's PATH, and on a machine where git lives somewhere the
1875
+ deployment's PATH does not look — a Homebrew prefix, a bundled git inside an
1876
+ IDE, a nix profile — the plugin's answer used to be "this machine has no git",
1877
+ which is both wrong and unactionable. So the word is a setting.
1878
+
1879
+ The resolution is deliberately one function and one variable: `gitExe` is what
1880
+ the config says, `gitCmd()` is how it is written into a command line, and the
1881
+ config read happens before any handler builds a command (see `onRpc`), so no
1882
+ command can be built from a half-loaded answer. */
1883
+
1884
+ /* Where the resolved binary is kept. `applyConfig` is the one place that writes
1885
+ it, so reading the config and running a command can never disagree. */
1886
+ let gitExe = 'git'
1887
+
1888
+ function applyConfig(config) {
1889
+ gitExe = isStr(config.gitPath) && config.gitPath.length > 0 ? config.gitPath : 'git'
1890
+ return config
1891
+ }
1892
+
1893
+ /* The command word every shell string is built from — quoted, because a path
1894
+ with a space in it is a perfectly good path. */
1895
+ function gitCmd() {
1896
+ return gitExe === 'git' ? 'git' : shq(gitExe)
1897
+ }
1898
+
1899
+ /* A path that is about to be put in front of every git command on this machine.
1900
+ It is the reader's own text, so it is not a threat to *them* — but a stray
1901
+ newline would cut the panel scripts in half, and a leading `-` would turn the
1902
+ command into an option. Both are refused here rather than quoted and hoped
1903
+ for; anything else (spaces, quotes) `shq` and `command -v` carry. */
1904
+ function cleanGitPath(value) {
1905
+ const trimmed = value.trim().slice(0, 400)
1906
+ if (trimmed.length === 0) return ''
1907
+ if (/[\u0000-\u001f\u007f]/.test(trimmed)) return ''
1908
+ if (trimmed.charAt(0) === '-') return ''
1909
+ return trimmed
1910
+ }
1911
+
1912
+ /* ── what the settings page shows ──
1913
+
1914
+ Three answers about the same word: what `command -v` resolves it to (the
1915
+ configured path itself, or the one on PATH), whether that thing runs at all,
1916
+ and its version. The read does not go through `gitGuard`: a toolchain probe
1917
+ that reports "no git" by failing to run is exactly the answer being asked
1918
+ for, so it must not be turned into "this is not a repository".
1919
+
1920
+ `P:` is printed even when `command -v` finds nothing (an empty line), because
1921
+ "the word is not there" and "the probe did not run" have to stay apart. */
1922
+ async function toolchainSnapshot() {
1923
+ const config = await readConfigFile()
1924
+ const probe = await invoke(
1925
+ 'p=$(command -v ' + gitCmd() + ' 2>/dev/null || true)\n'
1926
+ + "printf 'P:%s\\n' \"$p\"\n"
1927
+ + "printf 'V:%s\\n' \"$(" + gitCmd() + ' --version 2>&1 | head -1)"\n',
1928
+ {}, null, { timeoutMs: 10000 })
1929
+ let resolved = ''
1930
+ let version = ''
1931
+ const lines = probe.stdout.split('\n')
1932
+ for (let i = 0; i < lines.length; i += 1) {
1933
+ if (lines[i].indexOf('P:') === 0) resolved = lines[i].slice(2).trim()
1934
+ if (lines[i].indexOf('V:') === 0) version = lines[i].slice(2).trim()
1935
+ }
1936
+ /* A configured path that does not resolve is not the same failure as a machine
1937
+ with no git on PATH, and the fix is different — so it says which one it is. */
1938
+ const reason = resolved.length > 0
1939
+ ? ''
1940
+ : (config.gitPath.length > 0 ? 'configured-missing' : 'not-on-path')
1941
+ return {
1942
+ ok: true,
1943
+ configured: config.gitPath,
1944
+ fromPath: config.gitPath.length === 0,
1945
+ path: resolved,
1946
+ version: version,
1947
+ found: resolved.length > 0,
1948
+ reason: reason,
1949
+ platform: probe.exitCode === 0 ? 'ok' : 'probe-failed',
1950
+ }
1951
+ }
1952
+ /* ─────────────── who a commit is signed by ───────────────
1953
+
1954
+ `git commit` will not write a commit until it knows a name and an address, and
1955
+ when it does not it prints eight lines of English advice. The panel used to
1956
+ hand those eight lines to the reader; the two `git config` commands inside them
1957
+ are the real answer, and this is where the panel offers to run them.
1958
+
1959
+ Read through git and not through the config file: the effective value depends
1960
+ on the local file, the global file, the system file, `GIT_AUTHOR_NAME` and the
1961
+ command line, in an order only git knows (`git var GIT_AUTHOR_IDENT` is the
1962
+ same lookup the commit makes, and it is what `identityMissing` asks). One
1963
+ `--show-origin --get-regexp` answers "what is it" and "where did that come
1964
+ from" together, which is the pair the settings page has to show: a name that
1965
+ comes from `.git/config` is *this repository's*, and one that comes from
1966
+ `~/.gitconfig` is the machine's. */
1967
+
1968
+ /* The two values, each with the file it came from. `git config` prints
1969
+ `file:/path/to/config<TAB>user.name Ada`; the origin prefix is the part that
1970
+ says who is winning. */
1971
+ function parseIdentity(out) {
1972
+ const found = { name: '', email: '', nameOrigin: '', emailOrigin: '' }
1973
+ const lines = out.split('\n')
1974
+ for (let i = 0; i < lines.length; i += 1) {
1975
+ const line = lines[i]
1976
+ const tab = line.indexOf('\t')
1977
+ if (tab < 0) continue
1978
+ const origin = line.slice(0, tab)
1979
+ const rest = line.slice(tab + 1)
1980
+ const space = rest.indexOf(' ')
1981
+ if (space < 0) continue
1982
+ const key = rest.slice(0, space)
1983
+ const value = rest.slice(space + 1)
1984
+ if (key === 'user.name') { found.name = value; found.nameOrigin = origin }
1985
+ if (key === 'user.email') { found.email = value; found.emailOrigin = origin }
1986
+ }
1987
+ return found
1988
+ }
1989
+
1990
+ /* `file:/home/x/.gitconfig` is what git prints; the reader wants the path. */
1991
+ function originLabel(origin) {
1992
+ if (origin.length === 0) return ''
1993
+ if (origin.indexOf('file:') === 0) return origin.slice(5)
1994
+ return origin
1995
+ }
1996
+
1997
+ async function identitySnapshot(input) {
1998
+ const target = repoFrom(input, null)
1999
+ const inside = target !== undefined
2000
+ const args = inside ? argsAt(input, target) : {}
2001
+ /* 不知道是哪个仓库时,绝不去问「此刻生效的那一份」:`git config`(和 `git var`)
2002
+ 会按**当前目录**回答,而当前目录是 dsh 进程自己的目录,不是读者在看的东西 ——
2003
+ 拿它答出来的作者名去填「此刻生效」,就是把另一个仓库的身份说成这个仓库的。这时
2004
+ 只回答机器级的那一份,并明说不知道仓库(`insideRepo: false`),由界面自己说清。
2005
+ 这条是被 fixture 抓出来的:临时仓库里读到的 `user.name` 是插件自己仓库里的那个。 */
2006
+ const effective = inside
2007
+ ? await gitC(args, ['config', '--show-origin', '--get-regexp', '^user\\.(name|email)$'], null, {})
2008
+ : null
2009
+ /* 机器级的这一份按定义与目录无关,所以它在两种情况下都问。 */
2010
+ const global = await gitC(args, ['config', '--global', '--show-origin', '--get-regexp', '^user\\.(name|email)$'], null, {})
2011
+ const here = effective === null
2012
+ ? { name: '', email: '', nameOrigin: '', emailOrigin: '' }
2013
+ : parseIdentity(effective.stdout)
2014
+ const machine = parseIdentity(global.stdout)
2015
+ /* The same question the commit asks, so the settings page and the commit pane
2016
+ can never disagree about whether a commit would be refused. Not asked when
2017
+ there is no repository to ask about: "would a commit here be refused" is a
2018
+ question about a repository, and answering it from whatever directory the
2019
+ Host happens to sit in is the misreport this whole function avoids. */
2020
+ const missing = inside ? await identityMissing(args) : false
2021
+ return {
2022
+ ok: true, repo: inside ? target : null, insideRepo: inside,
2023
+ name: inside ? here.name : machine.name,
2024
+ email: inside ? here.email : machine.email,
2025
+ nameOrigin: inside ? originLabel(here.nameOrigin) : originLabel(machine.nameOrigin),
2026
+ emailOrigin: inside ? originLabel(here.emailOrigin) : originLabel(machine.emailOrigin),
2027
+ globalName: machine.name, globalEmail: machine.email,
2028
+ needsIdentity: missing,
2029
+ /* Both halves are the same value from the same file: a name set here but no
2030
+ address is the case git reports as "empty ident name", and the page has to
2031
+ be able to say which half is missing rather than "fill both". */
2032
+ nameMissing: (inside ? here.name : machine.name).length === 0,
2033
+ emailMissing: (inside ? here.email : machine.email).length === 0,
2034
+ }
2035
+ }
2036
+
2037
+ /* A value about to become one `git config` argument. Quoting is the shell's
2038
+ problem (`shq` handles it); what git itself would misread is a leading dash,
2039
+ and what would cut a command in half is a control character. */
2040
+ function cleanIdentValue(value) {
2041
+ const raw = isStr(value) ? value : ''
2042
+ const trimmed = raw.trim().slice(0, 200)
2043
+ if (trimmed.length === 0) return ''
2044
+ if (/[\u0000-\u001f\u007f]/.test(trimmed)) return null
2045
+ if (trimmed.charAt(0) === '-') return null
2046
+ return trimmed
2047
+ }
2048
+
2049
+ /* Written through git so that everything else on the machine sees it: a terminal,
2050
+ IDEA, a hook. The alternative — keeping the pair in this plugin's own config and
2051
+ passing `-c user.name=…` to every commit — would make the panel the only tool
2052
+ that knows who the author is, which is a worse surprise than the failure it
2053
+ fixes.
2054
+
2055
+ `scope` is 'global' (this machine) or 'local' (this repository). Nothing is
2056
+ written for a field left empty: `git config user.name ''` is how a reader ends
2057
+ up with git's "empty ident name" error in the first place, so an empty box
2058
+ means "leave this one alone" rather than "set it to nothing". */
2059
+ async function identitySave(input) {
2060
+ const scope = input != null && input.scope === 'local' ? 'local' : 'global'
2061
+ const target = repoFrom(input, null)
2062
+ if (scope === 'local' && target === undefined) {
2063
+ return { ok: false, error: 'no-path', stderr: '不知道该写进哪个仓库:这个会话没有工作区,也没有指定路径' }
2064
+ }
2065
+ const name = cleanIdentValue(input != null ? input.name : '')
2066
+ const email = cleanIdentValue(input != null ? input.email : '')
2067
+ if (name === null || email === null) {
2068
+ return { ok: false, error: 'bad-value', stderr: '名字和邮箱里不能有控制字符,也不能以 - 开头' }
2069
+ }
2070
+ if (name.length === 0 && email.length === 0) {
2071
+ return { ok: false, error: 'empty', stderr: '名字和邮箱至少要填一个' }
2072
+ }
2073
+ const flag = scope === 'local' ? '--local' : '--global'
2074
+ /* The sandbox is the session's, and that is decided from `args`: a global write
2075
+ has no repository to name, so the session id is what carries the policy. */
2076
+ const args = scope === 'local' ? argsAt(input, target) : (input != null && isStr(input.sessionId) ? { sessionId: input.sessionId } : {})
2077
+ const written = []
2078
+ if (name.length > 0) {
2079
+ const one = await git(args, ['config', flag, 'user.name', name], null, {})
2080
+ if (one.exitCode !== 0) return { ok: false, error: 'write-failed', field: 'user.name', stderr: one.stderr, stdout: one.stdout, sandboxDenied: one.sandboxDenied === true, noGit: gitMissing(one) }
2081
+ written.push('user.name')
2082
+ }
2083
+ if (email.length > 0) {
2084
+ const one = await git(args, ['config', flag, 'user.email', email], null, {})
2085
+ if (one.exitCode !== 0) return { ok: false, error: 'write-failed', field: 'user.email', stderr: one.stderr, stdout: one.stdout, sandboxDenied: one.sandboxDenied === true, noGit: gitMissing(one) }
2086
+ written.push('user.email')
2087
+ }
2088
+ /* Read back rather than echo the input: `git config` may have written something
2089
+ other than what was typed (a local value being overridden by a higher scope
2090
+ is the interesting one), and the page should show what git now answers. The
2091
+ read is told which repository to ask about — never left to fall back on the
2092
+ Host's own directory (see identitySnapshot). */
2093
+ const after = await identitySnapshot({ repo: target, sessionId: input != null ? input.sessionId : undefined })
2094
+ return {
2095
+ ok: true, scope: scope, written: written, repo: after.repo,
2096
+ name: after.name, email: after.email,
2097
+ nameOrigin: after.nameOrigin, emailOrigin: after.emailOrigin,
2098
+ globalName: after.globalName, globalEmail: after.globalEmail,
2099
+ needsIdentity: after.needsIdentity,
2100
+ }
2101
+ }
2505
2102
  /* Every request the Client can make, in one table. Each handler is registered
2506
2103
  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. */
2104
+ Package removes all of them, which is what makes the bridge's reload safe.
2105
+
2106
+ Each one waits for the plugin config before the handler runs, because the
2107
+ config decides which binary every command in this plugin starts with
2108
+ (`gitExe` — see 72-gitbin.js) and command lines are built synchronously inside
2109
+ the handlers. One cached read for the life of the process: the first request
2110
+ pays for it, and a "which git" answer can never be half-applied. */
2508
2111
  function onRpc(name, handler) {
2509
- ctx.effect(function () { return harness.handle(name, handler) }, 'dsh-git-idea rpc ' + name)
2112
+ ctx.effect(function () {
2113
+ return harness.handle(name, function (input) {
2114
+ return readConfigFile().then(function () { return handler(input) })
2115
+ })
2116
+ }, 'dsh-git-idea rpc ' + name)
2510
2117
  }
2511
2118
 
2512
2119
  onRpc('git/panel', function (input) { return panelSnapshot(input) })
@@ -2529,9 +2136,24 @@ onRpc('git/config', function () {
2529
2136
  })
2530
2137
 
2531
2138
  onRpc('git/config-save', function (input) {
2532
- return writeConfigFile(input != null ? input.config : null)
2139
+ /* 带上会话:这份文件在任何工作区之外,写它的策略来自这个会合(`sandboxFor`)。
2140
+ 没有会话时拿到的是部署默认那份(workspace-write),写到 ~/.dsh 会被拒 —— 而现在
2141
+ 被拒会在答复里说出来,不再是一个安静的 no-op。 */
2142
+ return writeConfigFile(input != null ? input.config : null, argsAt(input, null))
2533
2143
  })
2534
2144
 
2145
+ /* The two machine-level questions the settings page asks: which git, and who
2146
+ commits. Both are reads of the world outside the repository and neither is
2147
+ cached — the reader opening this page is asking "now", not "a moment ago". */
2148
+ onRpc('git/toolchain', function () { return toolchainSnapshot() })
2149
+
2150
+ onRpc('git/identity', function (input) { return identitySnapshot(input) })
2151
+
2152
+ /* The one mutation that is not about the repository at all: it writes the
2153
+ reader's own name and address into git's configuration. Explicit button, named
2154
+ scope, and nothing is written for a box left empty. */
2155
+ onRpc('git/identity-save', function (input) { return identitySave(input) })
2156
+
2535
2157
  /* Never cached: its whole purpose is to observe change. `paths` narrows the
2536
2158
  working-tree half of the signature to what is on screen — see watchCommand for
2537
2159
  what a whole-tree status costs on a slow mount. */
@@ -2576,16 +2198,34 @@ onRpc('git/unstage', function (input) {
2576
2198
  return panelMutate(input, ['restore', '--staged', '--'].concat(paths))
2577
2199
  })
2578
2200
 
2201
+ /* A failed commit is the one mutation whose failure can be about this machine
2202
+ instead of about the repository: git will not author a commit until it knows
2203
+ who the author is, and no amount of retrying here changes that. Asked of git
2204
+ itself, once, and only after the commit has already been refused — see
2205
+ `identityMissing`. The flag rides the same reply as `noGit` and
2206
+ `sandboxDenied`, so the client has one place to read all three.
2207
+
2208
+ Which mutations go through here is decided at the call site, because only the
2209
+ call site knows whether the command writes a commit object. `git add`,
2210
+ `git branch -d` and the aborts all run on a machine with no identity at all —
2211
+ hanging "this machine has no identity" on one of those would send the reader
2212
+ to fix something that is not broken. */
2213
+ async function commitMutation(input, argv, options) {
2214
+ const result = await panelMutate(input, argv, options)
2215
+ if (result.ok !== true) result.needsIdentity = await identityMissing(argsFor(input))
2216
+ return result
2217
+ }
2218
+
2579
2219
  onRpc('git/commit', function (input) {
2580
2220
  const message = input != null && isStr(input.message) ? input.message.trim() : ''
2581
2221
  if (message.length === 0) return { ok: false, error: 'a commit message is required' }
2582
2222
  if (input != null && input.stageAll === true) {
2583
2223
  return panelMutate(input, ['add', '-A']).then(function (staged) {
2584
2224
  if (staged.ok !== true) return staged
2585
- return panelMutate(input, ['commit', '-m', message])
2225
+ return commitMutation(input, ['commit', '-m', message])
2586
2226
  })
2587
2227
  }
2588
- return panelMutate(input, ['commit', '-m', message])
2228
+ return commitMutation(input, ['commit', '-m', message])
2589
2229
  })
2590
2230
 
2591
2231
  onRpc('git/checkout', async function (input) {
@@ -2604,12 +2244,25 @@ onRpc('git/checkout', async function (input) {
2604
2244
 
2605
2245
  const NET_SPAWN = { timeoutMs: 180000 }
2606
2246
 
2607
- onRpc('git/fetch', function (input) {
2608
- return panelMutate(input, ['fetch', '--all', '--prune'], { net: true, spawn: NET_SPAWN })
2247
+ /* The three arguments this plugin chooses about the network, each read from the
2248
+ plugin config at the moment it is used: `fetch --all` prunes only if asked,
2249
+ `pull` merges unless the reader prefers rebase, and a push to a branch with no
2250
+ upstream is left to the panel's own "set upstream and push" row unless the
2251
+ reader asked for it to just happen. git's own `push.default` and `pull.rebase`
2252
+ are not overridden — these are the flags this plugin adds on top. */
2253
+ async function netConfig() {
2254
+ return await readConfigFile()
2255
+ }
2256
+
2257
+ onRpc('git/fetch', async function (input) {
2258
+ const config = await netConfig()
2259
+ return panelMutate(input, config.fetchPrune === true ? ['fetch', '--all', '--prune'] : ['fetch', '--all'], { net: true, spawn: NET_SPAWN })
2609
2260
  })
2610
2261
 
2611
- onRpc('git/pull', function (input) {
2612
- return panelMutate(input, ['pull'], { net: true, spawn: NET_SPAWN })
2262
+ onRpc('git/pull', async function (input) {
2263
+ const config = await netConfig()
2264
+ /* A pull that merges writes a commit, so the identity can be what failed. */
2265
+ return commitMutation(input, config.pullRebase === true ? ['pull', '--rebase'] : ['pull'], { net: true, spawn: NET_SPAWN })
2613
2266
  })
2614
2267
 
2615
2268
  onRpc('git/push', function (input) {
@@ -2637,20 +2290,20 @@ onRpc('git/sequence', function (input) {
2637
2290
  if (op === 'merge') {
2638
2291
  if (action === 'start') {
2639
2292
  if (target.length === 0) return { ok: false, error: 'a branch or commit is required to merge' }
2640
- return panelMutate(input, ['merge', '--no-edit', target])
2293
+ return commitMutation(input, ['merge', '--no-edit', target])
2641
2294
  }
2642
- if (action === 'continue') return panelMutate(input, ['commit', '--no-edit'])
2295
+ if (action === 'continue') return commitMutation(input, ['commit', '--no-edit'])
2643
2296
  if (action === 'abort') return panelMutate(input, ['merge', '--abort'])
2644
2297
  return { ok: false, error: 'merge supports start, continue and abort' }
2645
2298
  }
2646
2299
 
2647
2300
  if (action === 'start') {
2648
2301
  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])
2302
+ if (op === 'revert') return commitMutation(input, ['revert', '--no-edit', target])
2303
+ if (input != null && input.record === true) return commitMutation(input, ['cherry-pick', '-x', target])
2304
+ return commitMutation(input, ['cherry-pick', target])
2652
2305
  }
2653
- if (action === 'continue') return panelMutate(input, ['-c', 'core.editor=true', op, '--continue'])
2306
+ if (action === 'continue') return commitMutation(input, ['-c', 'core.editor=true', op, '--continue'])
2654
2307
  if (action === 'abort') return panelMutate(input, [op, '--abort'])
2655
2308
  if (action === 'skip') return panelMutate(input, [op, '--skip'])
2656
2309
  return { ok: false, error: op + ' does not support ' + action }
@@ -2685,8 +2338,8 @@ onRpc('git/tag', function (input) {
2685
2338
 
2686
2339
  /* The body's own apply, plus the one thing the bridge used to own: the
2687
2340
  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. */
2341
+ present in every web profile; without it the panel goes quiet and nothing
2342
+ else changes. */
2690
2343
  export function apply(ctx, config) {
2691
2344
  ctx.inject(['webServer'], function (scope) {
2692
2345
  scope.effect(function () {
@@ -2696,6 +2349,6 @@ export function apply(ctx, config) {
2696
2349
  return plugin.apply(ctx, config)
2697
2350
  }
2698
2351
 
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']
2352
+ /* No hard dependency to declare. Every service the fragments use — \`shell\`,
2353
+ \`fs\`, \`timer\`, \`sandboxPolicy\`, \`sessions\` is read with \`ctx.get\` and
2354
+ guarded, and the RPC route waits for \`webServer\` through \`ctx.inject\` above. */