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.
- package/README.md +164 -62
- package/client/client.js +727 -120
- package/lib/index.js +634 -981
- package/package.json +3 -4
package/lib/index.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
/* GENERATED by build-package.mjs from
|
|
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\`
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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 + '
|
|
220
|
-
result.command = '
|
|
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
|
-
/*
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
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=$(
|
|
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=$(
|
|
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
|
|
1616
|
-
a case of exactly this — a fetch that
|
|
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=$(
|
|
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
|
|
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("
|
|
1654
|
-
"printf 'H:%s\\n' \"" + whenRepo("
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
2455
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
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 () {
|
|
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
|
-
|
|
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
|
|
2225
|
+
return commitMutation(input, ['commit', '-m', message])
|
|
2586
2226
|
})
|
|
2587
2227
|
}
|
|
2588
|
-
return
|
|
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
|
-
|
|
2608
|
-
|
|
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
|
-
|
|
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
|
|
2293
|
+
return commitMutation(input, ['merge', '--no-edit', target])
|
|
2641
2294
|
}
|
|
2642
|
-
if (action === 'continue') return
|
|
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
|
|
2650
|
-
if (input != null && input.record === true) return
|
|
2651
|
-
return
|
|
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
|
|
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
|
|
2689
|
-
|
|
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
|
-
/*
|
|
2700
|
-
\`
|
|
2701
|
-
|
|
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. */
|