@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.
- package/LICENSE +21 -0
- package/README.md +169 -2
- package/cordis.patch.yml +3 -0
- package/icon.png +0 -0
- package/icon.svg +1 -0
- package/index.js +171 -0
- package/lib/client.js +1790 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +68 -4
- package/src/ai-commit.js +194 -0
- package/src/git.js +399 -0
- package/src/operations.js +510 -0
|
@@ -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
|
+
}
|