@caozeal/dsh-next-git 0.0.0-stage → 0.1.3

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.
@@ -0,0 +1,510 @@
1
+ /**
2
+ * git-manager — RPC 操作实现。
3
+ *
4
+ * 每个操作都是一次独立的请求:解析 cwd → 找到仓库根 → 读写 git → 返回纯 JSON。
5
+ * `exec` 由 Host 入口注入(真实子进程或单测的假实现),因此本文件没有 I/O 依赖。
6
+ */
7
+ import { isAbsolute } from 'node:path'
8
+ import {
9
+ GitError,
10
+ classifyEntries,
11
+ gitFailure,
12
+ parseBranches,
13
+ parseBranchHeader,
14
+ parseGraph,
15
+ parsePorcelain,
16
+ parseRemotes,
17
+ parseStashes,
18
+ parseUnifiedDiff,
19
+ } from './git.js'
20
+ import { generateCommitMessage } from './ai-commit.js'
21
+
22
+ const READ_TIMEOUT = 30000
23
+ const WRITE_TIMEOUT = 30000
24
+ const NETWORK_TIMEOUT = 180000
25
+ const AI_TIMEOUT = 120000
26
+ const DIFF_MAX_CHARS = 400000
27
+
28
+ /** 支持的操作名。 */
29
+ const OPS = [
30
+ 'snapshot', 'graph', 'diff', 'stage', 'unstage', 'switchBranch', 'deleteBranch',
31
+ 'commit', 'fetch', 'pull', 'push', 'aiCommit',
32
+ ]
33
+
34
+ /** 只接受安全的 ref/远程名,避免被当作命令行选项。 */
35
+ const SAFE_REF = /^[A-Za-z0-9._/-]+$/
36
+
37
+ function assertCwd(cwd) {
38
+ if (typeof cwd !== 'string' || cwd.trim() === '') {
39
+ throw new GitError('缺少 cwd 参数', { code: 'invalid-params' })
40
+ }
41
+ if (!isAbsolute(cwd)) {
42
+ throw new GitError(`cwd 必须是绝对路径:${cwd}`, { code: 'invalid-params' })
43
+ }
44
+ if (cwd.includes('\0')) {
45
+ throw new GitError('cwd 含非法字符', { code: 'invalid-params' })
46
+ }
47
+ return cwd
48
+ }
49
+
50
+ function assertPaths(paths) {
51
+ if (!Array.isArray(paths) || paths.length === 0) {
52
+ throw new GitError('paths 不能为空', { code: 'invalid-params' })
53
+ }
54
+ return paths.map((path) => {
55
+ if (typeof path !== 'string' || path === '' || path.includes('\0')) {
56
+ throw new GitError('paths 含非法条目', { code: 'invalid-params' })
57
+ }
58
+ // 以 - 开头的路径会被 git 当成选项,补 ./ 前缀。
59
+ return path.startsWith('-') ? `./${path}` : path
60
+ })
61
+ }
62
+
63
+ function assertRef(value, label) {
64
+ if (typeof value !== 'string' || value.trim() === '') {
65
+ throw new GitError(`缺少 ${label} 参数`, { code: 'invalid-params' })
66
+ }
67
+ if (!SAFE_REF.test(value)) {
68
+ throw new GitError(`${label} 含非法字符:${value}`, { code: 'invalid-params' })
69
+ }
70
+ return value
71
+ }
72
+
73
+ function assertHash(hash) {
74
+ if (typeof hash !== 'string' || !/^[0-9a-f]{4,40}$/.test(hash)) {
75
+ throw new GitError(`hash 非法:${String(hash)}`, { code: 'invalid-params' })
76
+ }
77
+ return hash
78
+ }
79
+
80
+ function joinOutput(stdout, stderr) {
81
+ return [String(stdout || '').trim(), String(stderr || '').trim()].filter(Boolean).join('\n')
82
+ }
83
+
84
+ /**
85
+ * @param {{
86
+ * exec: (argv: string[], options: {cwd: string, signal?: AbortSignal, timeoutMs?: number}) => Promise<{code: number|null, stdout: string, stderr: string}>,
87
+ * ai?: () => ({llm?: unknown, selection?: object} | null),
88
+ * generateCommitMessage?: typeof generateCommitMessage,
89
+ * }} deps
90
+ */
91
+ export function createOperations(deps) {
92
+ const { exec } = deps
93
+ const ai = deps.ai ?? (() => null)
94
+ const generate = deps.generateCommitMessage ?? generateCommitMessage
95
+ const timeouts = {
96
+ read: READ_TIMEOUT,
97
+ write: WRITE_TIMEOUT,
98
+ network: NETWORK_TIMEOUT,
99
+ ai: AI_TIMEOUT,
100
+ ...(deps.timeouts ?? {}),
101
+ }
102
+
103
+ /**
104
+ * 运行一条 git 命令。
105
+ * @param {string[]} args 不含 `git` 本身的参数。
106
+ * @param {{cwd: string, signal?: AbortSignal, timeoutMs?: number, allowFailure?: boolean}} options
107
+ */
108
+ async function runGit(args, options) {
109
+ const { cwd, signal, timeoutMs = timeouts.read, allowFailure = false } = options
110
+ const argv = ['git', ...args]
111
+ const result = await exec(argv, { cwd, signal, timeoutMs })
112
+ if (result.code !== 0 && !allowFailure) {
113
+ throw gitFailure(argv, result)
114
+ }
115
+ return result
116
+ }
117
+
118
+ /**
119
+ * 解析仓库根。
120
+ * @returns {Promise<{root: string|null, error: string|null}>} 非仓库时 root 为 null,
121
+ * error 带 git 的 stderr(面板用它说明「为什么不是仓库」,例如权限/所有权问题)。
122
+ */
123
+ async function repoRoot(cwd, signal) {
124
+ const result = await runGit(['rev-parse', '--show-toplevel'], {
125
+ cwd,
126
+ signal,
127
+ allowFailure: true,
128
+ })
129
+ if (result.code === 0) {
130
+ const root = result.stdout.trim()
131
+ return { root: root === '' ? null : root, error: null }
132
+ }
133
+ const detail = [result.stderr, result.stdout].filter(Boolean).join('\n').trim()
134
+ return {
135
+ root: null,
136
+ error: detail || `git rev-parse --show-toplevel 退出码 ${result.code}`,
137
+ }
138
+ }
139
+
140
+ /** 当前分支名;detached 时返回 null。 */
141
+ async function currentBranch(root, signal) {
142
+ const result = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], {
143
+ cwd: root,
144
+ signal,
145
+ allowFailure: true,
146
+ })
147
+ if (result.code !== 0) return null
148
+ const name = result.stdout.trim()
149
+ if (name === '' || name === 'HEAD') return null
150
+ return name
151
+ }
152
+
153
+ async function snapshot(params, signal) {
154
+ const cwd = assertCwd(params.cwd)
155
+ const resolved = await repoRoot(cwd, signal)
156
+ if (!resolved.root) {
157
+ return {
158
+ repo: { isRepo: false, error: resolved.error },
159
+ branches: [],
160
+ remotes: [],
161
+ status: { clean: true, staged: [], unstaged: [], untracked: [], conflicted: [] },
162
+ stashes: [],
163
+ }
164
+ }
165
+ const root = resolved.root
166
+ const [statusRes, branchesRes, remotesRes, stashRes, headRes, urlRes] = await Promise.all([
167
+ runGit(['status', '--porcelain=v1', '-z', '--branch', '--untracked-files=all'], {
168
+ cwd: root,
169
+ signal,
170
+ }),
171
+ runGit([
172
+ 'for-each-ref',
173
+ 'refs/heads',
174
+ 'refs/remotes',
175
+ '--format=%(refname)%00%(refname:short)%00%(objectname:short)%00%(upstream:short)%00%(upstream:track)%00%(committerdate:iso8601)%00%(subject)',
176
+ ], { cwd: root, signal, allowFailure: true }),
177
+ runGit(['remote', '-v'], { cwd: root, signal, allowFailure: true }),
178
+ runGit(['stash', 'list', '--format=%gd%x00%s'], { cwd: root, signal, allowFailure: true }),
179
+ runGit(['rev-parse', '--short', 'HEAD'], { cwd: root, signal, allowFailure: true }),
180
+ runGit(['config', '--get', 'remote.origin.url'], { cwd: root, signal, allowFailure: true }),
181
+ ])
182
+ const parsed = parsePorcelain(statusRes.stdout)
183
+ const head = parseBranchHeader(parsed.branchLine)
184
+ const groups = classifyEntries(parsed.entries)
185
+ const branches = parseBranches(branchesRes.stdout)
186
+ for (const branch of branches) {
187
+ if (branch.kind === 'local' && head.branch !== null && branch.name === head.branch) branch.current = true
188
+ }
189
+ const remotes = parseRemotes(remotesRes.stdout)
190
+ const originUrl = headRes.code === 0 ? headRes.stdout.trim() : ''
191
+ const configuredUrl = urlRes.code === 0 ? urlRes.stdout.trim() : ''
192
+ const remoteUrl = configuredUrl || (remotes[0] && remotes[0].url) || ''
193
+ const clean = parsed.entries.length === 0
194
+ return {
195
+ repo: {
196
+ isRepo: true,
197
+ root,
198
+ name: root.split('/').filter(Boolean).pop() || root,
199
+ branch: head.branch,
200
+ detached: head.detached,
201
+ head: originUrl || null,
202
+ upstream: head.upstream,
203
+ upstreamGone: head.upstreamGone,
204
+ ahead: head.ahead,
205
+ behind: head.behind,
206
+ remoteUrl: remoteUrl || null,
207
+ },
208
+ branches,
209
+ remotes,
210
+ status: { clean, ...groups },
211
+ stashes: parseStashes(stashRes.stdout),
212
+ }
213
+ }
214
+
215
+ async function graph(params, signal) {
216
+ const cwd = assertCwd(params.cwd)
217
+ const resolved = await repoRoot(cwd, signal)
218
+ const root = resolved.root
219
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
220
+ const requested = Number.isFinite(params.limit) ? Math.trunc(params.limit) : 80
221
+ const limit = Math.min(300, Math.max(20, requested))
222
+ const result = await runGit([
223
+ 'log',
224
+ '--graph',
225
+ '--date=relative',
226
+ '--decorate=short',
227
+ '--pretty=format:%H%x1f%h%x1f%an%x1f%ar%x1f%D%x1f%s',
228
+ '-n', String(limit),
229
+ '--all',
230
+ ], { cwd: root, signal, allowFailure: true })
231
+ if (result.code !== 0) {
232
+ // 空仓库(无提交)不是错误。
233
+ const message = `${result.stderr || ''}`
234
+ if (/does not have any commits yet|unknown revision|bad default revision/i.test(message)) {
235
+ return { lines: [], truncated: false }
236
+ }
237
+ throw gitFailure(['git', 'log'], result)
238
+ }
239
+ const lines = parseGraph(result.stdout)
240
+ return { lines, truncated: false }
241
+ }
242
+
243
+ async function diff(params, signal) {
244
+ const cwd = assertCwd(params.cwd)
245
+ const resolved = await repoRoot(cwd, signal)
246
+ const root = resolved.root
247
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
248
+ const mode = params.mode
249
+ if (!['worktree', 'staged', 'commit', 'untracked'].includes(mode)) {
250
+ throw new GitError(`未知的 diff 模式:${String(mode)}`, { code: 'invalid-params' })
251
+ }
252
+ const path = typeof params.path === 'string' && params.path !== '' ? params.path : ''
253
+ const safePath = path !== '' && path.startsWith('-') ? `./${path}` : path
254
+ /** @type {string[]} */
255
+ let args
256
+ if (mode === 'worktree') {
257
+ args = ['diff', '--no-color', '--']
258
+ if (safePath) args.push(safePath)
259
+ } else if (mode === 'staged') {
260
+ args = ['diff', '--no-color', '--cached', '--']
261
+ if (safePath) args.push(safePath)
262
+ } else if (mode === 'commit') {
263
+ const hash = assertHash(params.hash)
264
+ args = ['show', '--no-color', '--format=', '--patch', hash]
265
+ if (safePath) args.push('--', safePath)
266
+ } else {
267
+ if (!safePath) throw new GitError('查看未跟踪文件差异需要 path', { code: 'invalid-params' })
268
+ args = ['diff', '--no-color', '--no-index', '--', '/dev/null', safePath]
269
+ }
270
+ const result = await runGit(args, { cwd: root, signal, allowFailure: true })
271
+ // git diff --no-index 有差异时退出码为 1;其它非 0 才是失败。
272
+ if (result.code !== 0 && result.code !== 1) throw gitFailure(['git', ...args], result)
273
+ let raw = result.stdout
274
+ let truncated = false
275
+ if (raw.length > DIFF_MAX_CHARS) {
276
+ raw = raw.slice(0, DIFF_MAX_CHARS)
277
+ truncated = true
278
+ }
279
+ const parsed = parseUnifiedDiff(raw)
280
+ return {
281
+ mode,
282
+ path: safePath,
283
+ binary: parsed.binary,
284
+ additions: parsed.additions,
285
+ deletions: parsed.deletions,
286
+ hunks: parsed.hunks,
287
+ truncated,
288
+ raw,
289
+ }
290
+ }
291
+
292
+ async function stage(params, signal) {
293
+ const cwd = assertCwd(params.cwd)
294
+ const resolved = await repoRoot(cwd, signal)
295
+ const root = resolved.root
296
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
297
+ const args = params.all === true ? ['add', '-A'] : ['add', '--', ...assertPaths(params.paths)]
298
+ const result = await runGit(args, { cwd: root, signal, timeoutMs: timeouts.write })
299
+ return { output: joinOutput(result.stdout, result.stderr) }
300
+ }
301
+
302
+ async function unstage(params, signal) {
303
+ const cwd = assertCwd(params.cwd)
304
+ const resolved = await repoRoot(cwd, signal)
305
+ const root = resolved.root
306
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
307
+ const args = params.all === true
308
+ ? ['reset', '-q']
309
+ : ['restore', '--staged', '--', ...assertPaths(params.paths)]
310
+ const result = await runGit(args, { cwd: root, signal, timeoutMs: timeouts.write })
311
+ return { output: joinOutput(result.stdout, result.stderr) }
312
+ }
313
+
314
+ async function switchBranch(params, signal) {
315
+ const cwd = assertCwd(params.cwd)
316
+ const resolved = await repoRoot(cwd, signal)
317
+ const root = resolved.root
318
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
319
+ const name = assertRef(params.name, 'name')
320
+ /** @type {{code: number|null, stdout: string, stderr: string}} */
321
+ let result
322
+ if (params.create === true) {
323
+ const args = ['checkout', '-b', name]
324
+ if (params.from !== undefined) args.push(assertRef(params.from, 'from'))
325
+ result = await runGit(args, { cwd: root, signal, timeoutMs: timeouts.write })
326
+ } else {
327
+ // 远程分支:本地不存在时建立同名跟踪分支(origin/feat → feat)。
328
+ const remoteRefs = await runGit(
329
+ ['for-each-ref', '--format=%(refname:short)', 'refs/remotes'],
330
+ { cwd: root, signal, allowFailure: true },
331
+ )
332
+ const isRemote = name.includes('/') && remoteRefs.stdout.split('\n').map((line) => line.trim()).includes(name)
333
+ if (isRemote) {
334
+ const local = name.split('/').slice(1).join('/')
335
+ const created = await runGit(['checkout', '-b', local, '--track', name], {
336
+ cwd: root,
337
+ signal,
338
+ timeoutMs: timeouts.write,
339
+ allowFailure: true,
340
+ })
341
+ if (created.code !== 0) {
342
+ result = await runGit(['checkout', local], { cwd: root, signal, timeoutMs: timeouts.write })
343
+ } else {
344
+ result = created
345
+ }
346
+ } else {
347
+ result = await runGit(['checkout', name], { cwd: root, signal, timeoutMs: timeouts.write })
348
+ }
349
+ }
350
+ return { branch: await currentBranch(root, signal), output: joinOutput(result.stdout, result.stderr) }
351
+ }
352
+
353
+ async function deleteBranch(params, signal) {
354
+ const cwd = assertCwd(params.cwd)
355
+ const resolved = await repoRoot(cwd, signal)
356
+ const root = resolved.root
357
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
358
+ const name = assertRef(params.name, 'name')
359
+ const flag = params.force === true ? '-D' : '-d'
360
+ const result = await runGit(['branch', flag, name], { cwd: root, signal, timeoutMs: timeouts.write })
361
+ return { output: joinOutput(result.stdout, result.stderr) }
362
+ }
363
+
364
+ async function commit(params, signal) {
365
+ const cwd = assertCwd(params.cwd)
366
+ const resolved = await repoRoot(cwd, signal)
367
+ const root = resolved.root
368
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
369
+ const message = typeof params.message === 'string' ? params.message.trim() : ''
370
+ if (message === '') throw new GitError('提交信息不能为空', { code: 'invalid-params' })
371
+ if (message.length > 5000) throw new GitError('提交信息过长(上限 5000 字)', { code: 'invalid-params' })
372
+ if (params.stageAll === true) {
373
+ await runGit(['add', '-A'], { cwd: root, signal, timeoutMs: timeouts.write })
374
+ }
375
+ const result = await runGit(['commit', '-m', message], {
376
+ cwd: root,
377
+ signal,
378
+ timeoutMs: timeouts.write,
379
+ })
380
+ const hashRes = await runGit(['rev-parse', '--short', 'HEAD'], { cwd: root, signal, allowFailure: true })
381
+ const subjectRes = await runGit(['log', '-1', '--pretty=format:%s'], { cwd: root, signal, allowFailure: true })
382
+ return {
383
+ hash: hashRes.code === 0 ? hashRes.stdout.trim() : null,
384
+ subject: subjectRes.code === 0 ? subjectRes.stdout.trim() : message,
385
+ output: joinOutput(result.stdout, result.stderr),
386
+ }
387
+ }
388
+
389
+ async function fetchRemote(params, signal) {
390
+ const cwd = assertCwd(params.cwd)
391
+ const resolved = await repoRoot(cwd, signal)
392
+ const root = resolved.root
393
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
394
+ const args = params.remote !== undefined
395
+ ? ['fetch', assertRef(params.remote, 'remote')]
396
+ : ['fetch', '--all', '--prune']
397
+ const result = await runGit(args, { cwd: root, signal, timeoutMs: timeouts.network })
398
+ return { output: joinOutput(result.stdout, result.stderr) }
399
+ }
400
+
401
+ async function pull(params, signal) {
402
+ const cwd = assertCwd(params.cwd)
403
+ const resolved = await repoRoot(cwd, signal)
404
+ const root = resolved.root
405
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
406
+ const result = await runGit(['pull'], { cwd: root, signal, timeoutMs: timeouts.network })
407
+ return {
408
+ branch: await currentBranch(root, signal),
409
+ output: joinOutput(result.stdout, result.stderr),
410
+ }
411
+ }
412
+
413
+ async function push(params, signal) {
414
+ const cwd = assertCwd(params.cwd)
415
+ const resolved = await repoRoot(cwd, signal)
416
+ const root = resolved.root
417
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
418
+ const branch = await currentBranch(root, signal)
419
+ if (!branch) throw new GitError('当前处于 detached HEAD,无法推送', { code: 'git-failed' })
420
+ const upstream = await runGit(
421
+ ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'],
422
+ { cwd: root, signal, allowFailure: true },
423
+ )
424
+ const hasUpstream = upstream.code === 0 && upstream.stdout.trim() !== ''
425
+ const args = (params.setUpstream === true || !hasUpstream)
426
+ ? ['push', '-u', 'origin', branch]
427
+ : ['push']
428
+ const result = await runGit(args, { cwd: root, signal, timeoutMs: timeouts.network })
429
+ return { branch, output: joinOutput(result.stdout, result.stderr) }
430
+ }
431
+
432
+ async function aiCommit(params, signal) {
433
+ const cwd = assertCwd(params.cwd)
434
+ const resolved = await repoRoot(cwd, signal)
435
+ const root = resolved.root
436
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
437
+ const hint = typeof params.hint === 'string' ? params.hint.slice(0, 500) : ''
438
+ const context = ai() ?? {}
439
+ return generate({
440
+ runGit: (args, options = {}) => runGit(args, {
441
+ cwd: root,
442
+ signal,
443
+ timeoutMs: timeouts.read,
444
+ ...options,
445
+ }),
446
+ cwd: root,
447
+ hint,
448
+ signal,
449
+ llm: context.llm,
450
+ selection: context.selection,
451
+ })
452
+ }
453
+
454
+ const handlers = {
455
+ snapshot,
456
+ graph,
457
+ diff,
458
+ stage,
459
+ unstage,
460
+ switchBranch,
461
+ deleteBranch,
462
+ commit,
463
+ fetch: fetchRemote,
464
+ pull,
465
+ push,
466
+ aiCommit,
467
+ }
468
+
469
+ /**
470
+ * 分发一次请求。
471
+ * @param {string} op
472
+ * @param {Record<string, unknown>} params
473
+ * @param {AbortSignal} [signal]
474
+ */
475
+ async function handle(op, params, signal) {
476
+ if (typeof op !== 'string' || !OPS.includes(op)) {
477
+ throw new GitError(`未知操作:${String(op)}`, { code: 'invalid-params' })
478
+ }
479
+ if (params === null || typeof params !== 'object') {
480
+ throw new GitError('参数必须是 JSON 对象', { code: 'invalid-params' })
481
+ }
482
+ const timeout = op === 'aiCommit'
483
+ ? timeouts.ai
484
+ : op === 'fetch' || op === 'pull' || op === 'push'
485
+ ? timeouts.network
486
+ : timeouts.read
487
+ const controller = new AbortController()
488
+ const onOuterAbort = () => controller.abort()
489
+ if (signal) {
490
+ if (signal.aborted) controller.abort()
491
+ else signal.addEventListener('abort', onOuterAbort, { once: true })
492
+ }
493
+ const timer = setTimeout(() => controller.abort(), timeout)
494
+ try {
495
+ return await handlers[op](params, controller.signal)
496
+ } catch (error) {
497
+ if (error instanceof GitError) throw error
498
+ if (controller.signal.aborted) {
499
+ throw new GitError(`git ${op} 超时(${timeout}ms)或被取消`, { code: 'timeout' })
500
+ }
501
+ const message = error && error.message ? error.message : String(error)
502
+ throw new GitError(`git ${op} 失败:${message}`, { code: 'internal' })
503
+ } finally {
504
+ clearTimeout(timer)
505
+ if (signal) signal.removeEventListener('abort', onOuterAbort)
506
+ }
507
+ }
508
+
509
+ return { handle, ops: [...OPS], runGit }
510
+ }