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