@dsh-xhl/dsh-git-gui 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/lib/service.js ADDED
@@ -0,0 +1,932 @@
1
+ /**
2
+ * GitService — the Host half of dsh-git-gui.
3
+ *
4
+ * Registered under the cordis key `gitService` with the Typert wire namespace
5
+ * `git`, so every `@Remote`-marked method below becomes a browser-callable
6
+ * endpoint `git/<method>` through the API gateway's source (SRC) mode:
7
+ * the client calls `ctx.connection.rpc.call('/api', 'git/status', {args})`.
8
+ *
9
+ * SRC constraints observed here:
10
+ * - every method signature is plain unique identifiers (`cwd`, `signal`),
11
+ * because the gateway parses parameter names from the function source;
12
+ * - the optional final parameter MUST be named `signal` (injected AbortSignal);
13
+ * - business errors never throw: they return `{ok:false, code, message}` so
14
+ * the browser keeps structured error codes instead of a folded `internal`.
15
+ *
16
+ * Decorator note: this package ships plain JavaScript (no build step), so the
17
+ * `@Remote` stage-3 decorator is applied manually with a standards-shaped
18
+ * decorator context — the exact contract the compiled monorepo emits
19
+ * (`__esDecorate` + instance initializers).
20
+ */
21
+
22
+ import fs from 'node:fs'
23
+ import path from 'node:path'
24
+ import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
25
+ import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm'
26
+ import { gitRead, gitWrite, GitError, GIT_ERROR_CODES, runGit } from './runner.js'
27
+ import {
28
+ parseStatusPorcelainV2,
29
+ parseUnifiedDiff,
30
+ parseLog,
31
+ parseRefs,
32
+ parseStashList,
33
+ parseRemotes,
34
+ sortStatusFiles,
35
+ } from './parse.js'
36
+
37
+ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
38
+ const MAX_PATHS = 500
39
+ const MAX_MESSAGE = 50_000
40
+ const MAX_UNTRACKED_PREVIEW = 512 * 1024
41
+ const NEGATIVE_TTL_MS = 5_000
42
+
43
+ const remoteInitializers = []
44
+ /**
45
+ * @param {object} proto class prototype
46
+ * @param {string} name public method name
47
+ */
48
+ function markRemote(proto, name) {
49
+ Remote(proto[name], {
50
+ kind: 'method',
51
+ name,
52
+ static: false,
53
+ private: false,
54
+ addInitializer(fn) { remoteInitializers.push(fn) },
55
+ })
56
+ }
57
+
58
+ /** Validate a batch of pathspecs coming from the wire. */
59
+ function sanitizePaths(paths) {
60
+ if (!Array.isArray(paths)) return { ok: false, message: 'paths must be an array' }
61
+ if (paths.length === 0) return { ok: false, message: '没有选择任何文件' }
62
+ if (paths.length > MAX_PATHS) return { ok: false, message: `一次最多操作 ${MAX_PATHS} 个文件` }
63
+ for (const p of paths) {
64
+ if (typeof p !== 'string' || p === '') return { ok: false, message: '非法路径' }
65
+ if (p.includes('\0') || p.startsWith('-') || p.includes('\n') || p.includes('\r')) {
66
+ return { ok: false, message: `非法路径: ${p.slice(0, 80)}` }
67
+ }
68
+ }
69
+ return { ok: true, paths }
70
+ }
71
+
72
+ /** Validate a branch name the wire asked us to create/switch to. */
73
+ function sanitizeBranch(name) {
74
+ if (typeof name !== 'string' || name === '') return { ok: false, message: '分支名不能为空' }
75
+ if (name.length > 240) return { ok: false, message: '分支名过长' }
76
+ if (name.startsWith('-') || name.includes(' ') || name.includes('..') || name.includes('\\')) {
77
+ return { ok: false, message: '非法分支名' }
78
+ }
79
+ if (/[~^:?*\[\]]/.test(name)) return { ok: false, message: '分支名包含非法字符' }
80
+ if (name.startsWith('refs/') || /^(HEAD|FETCH_HEAD|ORIG_HEAD|MERGE_HEAD)$/.test(name)) {
81
+ return { ok: false, message: '该名称是保留引用' }
82
+ }
83
+ return { ok: true, name }
84
+ }
85
+
86
+ function sanitizeCommit(commit, allowHeadExp = false) {
87
+ if (typeof commit !== 'string' || commit === '') return { ok: false, message: '目标为空' }
88
+ if (allowHeadExp && /^HEAD(~[0-9]{1,3})?$/.test(commit)) return { ok: true, commit }
89
+ if (/^[0-9a-fA-F]{7,40}$/.test(commit)) return { ok: true, commit }
90
+ return { ok: false, message: '非法的提交哈希' }
91
+ }
92
+
93
+ function sanitizeStashRef(ref) {
94
+ if (typeof ref !== 'string') return { ok: false, message: '非法 stash 引用' }
95
+ if (/^stash@\{\d+\}$/.test(ref)) return { ok: true, ref }
96
+ return { ok: false, message: '非法 stash 引用' }
97
+ }
98
+
99
+ /**
100
+ * Deep-clean a value for the Typert boundary: SRC results must be strictly
101
+ * JSON-safe (the gateway's `assertJsonValue` rejects undefined, non-finite
102
+ * numbers, functions, and cyclic values). `undefined` becomes `null` so the
103
+ * object shape is preserved.
104
+ * @param {unknown} value
105
+ * @returns {unknown}
106
+ */
107
+ function scrubJson(value, seen = new Set()) {
108
+ if (value === undefined || value === null || typeof value === 'string' || typeof value === 'boolean') {
109
+ return value === undefined ? null : value
110
+ }
111
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null
112
+ if (typeof value !== 'object') return null
113
+ if (seen.has(value)) return null
114
+ seen.add(value)
115
+ try {
116
+ if (Array.isArray(value)) {
117
+ const out = new Array(value.length)
118
+ for (let i = 0; i < value.length; i++) out[i] = scrubJson(value[i], seen)
119
+ return out
120
+ }
121
+ const out = {}
122
+ for (const key of Object.keys(value)) {
123
+ out[key] = scrubJson(value[key], seen)
124
+ }
125
+ return out
126
+ } finally {
127
+ seen.delete(value)
128
+ }
129
+ }
130
+
131
+ export class GitService extends TypertRemoteService {
132
+ /**
133
+ * @param {import('@deepseek-ai/cordis').Context} ctx
134
+ * @param {import('./activity.js').ActivityTracker} tracker
135
+ */
136
+ constructor(ctx, tracker) {
137
+ super(ctx, 'gitService', { namespace: 'git' })
138
+ for (const initializer of remoteInitializers) initializer.call(this)
139
+ this.tracker = tracker
140
+ this.versionCache = undefined
141
+ /** @type {Map<string, {root: string|null, nested: number, at: number}>} session-cwd → discovery */
142
+ this.rootCache = new Map()
143
+ }
144
+
145
+ /**
146
+ * Wrap a body: GitError and unexpected errors become `{ok:false}`; every
147
+ * successful result passes through `scrubJson` so the Typert gateway's
148
+ * strict JSON boundary validation never rejects it.
149
+ */
150
+ async guard(body) {
151
+ try {
152
+ return scrubJson(await body())
153
+ } catch (error) {
154
+ if (error instanceof GitError) {
155
+ return scrubJson({ ok: false, code: error.code, message: error.message, detail: error.detail })
156
+ }
157
+ return scrubJson({
158
+ ok: false,
159
+ code: GIT_ERROR_CODES.GIT,
160
+ message: `内部错误: ${String(error?.message ?? error).slice(0, 300)}`,
161
+ })
162
+ }
163
+ }
164
+
165
+ // ── read surface ──────────────────────────────────────────────────────────
166
+
167
+ /**
168
+ * Repository detection + git version.
169
+ * @returns {{ok:true, repo:boolean, root?:string, gitVersion?:string} | {ok:false,...}}
170
+ */
171
+ async check(cwd, signal) {
172
+ return this.guard(async () => {
173
+ const version = await this.gitVersion()
174
+ const { root, nested } = await this.resolveRoot(cwd)
175
+ if (root === null) {
176
+ return { ok: true, repo: false, nested, gitVersion: version }
177
+ }
178
+ return { ok: true, repo: true, root, nested, gitVersion: version }
179
+ })
180
+ }
181
+
182
+ async gitVersion() {
183
+ if (this.versionCache !== undefined) return this.versionCache
184
+ try {
185
+ const { stdout } = await runGit(this.ctx, process.cwd(), ['--version'], { timeoutMs: 10_000 })
186
+ this.versionCache = stdout.trim()
187
+ } catch {
188
+ this.versionCache = ''
189
+ }
190
+ return this.versionCache
191
+ }
192
+
193
+ /**
194
+ * Resolve the effective git root for a session cwd.
195
+ *
196
+ * git only discovers repositories by walking UP from the cwd. When the
197
+ * session workspace itself is not a repo (e.g. the repo lives in a
198
+ * subdirectory like `dsh-git-gui/`), scan one level down for a single
199
+ * nested repository and operate on it.
200
+ *
201
+ * @param {string} cwd session workspace root
202
+ * @returns {Promise<{root: string|null, nested: number}>}
203
+ */
204
+ async resolveRoot(cwd) {
205
+ const key = path.resolve(cwd).toLowerCase()
206
+ const cached = this.rootCache.get(key)
207
+ if (cached !== undefined) {
208
+ // positive discoveries stay cached; negatives expire so a repo created
209
+ // later (init in the panel, another tool) is picked up quickly
210
+ if (cached.root !== null || Date.now() - cached.at < NEGATIVE_TTL_MS) return cached
211
+ }
212
+ let discovery = { root: null, nested: 0, at: Date.now() }
213
+ try {
214
+ const result = await runGit(this.ctx, cwd, ['rev-parse', '--show-toplevel'], {})
215
+ if (result.exitCode === 0) {
216
+ const root = result.stdout.trim()
217
+ if (root !== '') discovery = { root, nested: 0, at: Date.now() }
218
+ }
219
+ } catch { /* not a repo at/above cwd */ }
220
+ if (discovery.root === null) {
221
+ discovery = { ...(await this.findNestedRepo(cwd)), at: Date.now() }
222
+ }
223
+ this.rootCache.set(key, discovery)
224
+ return discovery
225
+ }
226
+
227
+ /**
228
+ * Scan direct subdirectories of cwd for nested `.git` markers (depth 1).
229
+ * Returns the repo root when exactly one is found; several nested repos
230
+ * are reported via `nested` > 1 without picking a winner.
231
+ */
232
+ async findNestedRepo(cwd) {
233
+ let entries = []
234
+ try {
235
+ entries = fs.readdirSync(cwd, { withFileTypes: true })
236
+ } catch {
237
+ return { root: null, nested: 0 }
238
+ }
239
+ const roots = []
240
+ let scanned = 0
241
+ for (const entry of entries) {
242
+ if (!entry.isDirectory()) continue
243
+ const name = entry.name
244
+ if (name === 'node_modules' || name === '.git' || name.startsWith('.')) continue
245
+ if (++scanned > 300) break
246
+ const sub = path.join(cwd, name)
247
+ let marker = false
248
+ try {
249
+ marker = fs.existsSync(path.join(sub, '.git'))
250
+ } catch { continue }
251
+ if (!marker) continue
252
+ try {
253
+ const result = await runGit(this.ctx, sub, ['rev-parse', '--show-toplevel'], {})
254
+ if (result.exitCode === 0 && result.stdout.trim() !== '') {
255
+ roots.push(result.stdout.trim())
256
+ }
257
+ } catch { /* marker exists but unusable */ }
258
+ if (roots.length > 1) break
259
+ }
260
+ if (roots.length === 1) return { root: roots[0], nested: 1 }
261
+ if (roots.length > 1) return { root: null, nested: roots.length }
262
+ return { root: null, nested: 0 }
263
+ }
264
+
265
+ /** Effective root or a structured NOT_REPO failure. */
266
+ async requireRoot(cwd) {
267
+ const { root } = await this.resolveRoot(cwd)
268
+ if (root === null) throw new GitError(GIT_ERROR_CODES.NOT_REPO, '当前目录不是 Git 仓库 (not a git repository)')
269
+ return root
270
+ }
271
+
272
+ /**
273
+ * Working tree status (porcelain v2) + branch facts.
274
+ * @returns {{ok:true, branch:object, files:object[]} | {ok:false,...}}
275
+ */
276
+ async status(cwd, signal) {
277
+ return this.guard(async () => {
278
+ const root = await this.requireRoot(cwd)
279
+ const { stdout } = await gitRead(this.ctx, root, ['status', '--porcelain=v2', '-z', '--branch'], { signal })
280
+ const parsed = parseStatusPorcelainV2(stdout)
281
+ return { ok: true, branch: parsed.branch, files: sortStatusFiles(parsed.files) }
282
+ })
283
+ }
284
+
285
+ /**
286
+ * Unified diff for one path.
287
+ * @param {string} cwd workspace root
288
+ * @param {string} path repo-relative path
289
+ * @param {boolean} staged true = index vs HEAD; false = worktree vs index
290
+ * @param {string|null} base optional commit to diff the worktree against
291
+ * @param {boolean} untracked the path is untracked: synthesize an all-added file
292
+ * @returns {{ok:true, diff:object} | {ok:false,...}}
293
+ */
294
+ async diff(cwd, path, staged, base, untracked, signal) {
295
+ return this.guard(async () => {
296
+ const root = await this.requireRoot(cwd)
297
+ if (typeof path !== 'string' || path === '' || path.includes('\0') || path.startsWith('-')) {
298
+ return { ok: false, code: GIT_ERROR_CODES.NOT_FOUND, message: '非法路径' }
299
+ }
300
+ if (untracked === true) return this.untrackedFileDiff(root, path)
301
+ const args = ['diff', '--no-ext-diff', '--no-color', '--src-prefix=a/', '--dst-prefix=b/']
302
+ if (staged === true) {
303
+ args.push('--cached')
304
+ } else if (typeof base === 'string' && base !== '') {
305
+ const commit = sanitizeCommit(base)
306
+ if (!commit.ok) return commit
307
+ args.push(commit.commit)
308
+ }
309
+ args.push('--', path)
310
+ let stdout
311
+ try {
312
+ ;({ stdout } = await gitRead(this.ctx, root, args, { signal, timeoutMs: 45_000 }))
313
+ } catch (error) {
314
+ // `git diff --cached` needs a HEAD; empty repos diff against the empty tree.
315
+ if (staged === true && error instanceof GitError && error.code === GIT_ERROR_CODES.NO_COMMITS) {
316
+ const fallback = ['diff', '--no-ext-diff', '--no-color', '--src-prefix=a/', '--dst-prefix=b/', '--cached', EMPTY_TREE, '--', path]
317
+ ;({ stdout } = await gitRead(this.ctx, root, fallback, { signal, timeoutMs: 45_000 }))
318
+ } else {
319
+ throw error
320
+ }
321
+ }
322
+ return { ok: true, diff: parseUnifiedDiff(stdout), raw: stdout }
323
+ })
324
+ }
325
+
326
+ /**
327
+ * Read one workspace file for preview: returns plain JSON-safe content
328
+ * facts ({content, binary, tooLarge, size}) or a structured failure.
329
+ */
330
+ readFilePreview(root, filePath) {
331
+ const rootAbs = path.resolve(root)
332
+ const absolute = path.resolve(rootAbs, filePath)
333
+ if (!absolute.toLowerCase().startsWith(rootAbs.toLowerCase() + path.sep)) {
334
+ return { ok: false, code: GIT_ERROR_CODES.NOT_FOUND, message: '路径越界' }
335
+ }
336
+ let stat = null
337
+ try {
338
+ stat = fs.statSync(absolute)
339
+ } catch {
340
+ return { ok: false, code: GIT_ERROR_CODES.NOT_FOUND, message: '文件不存在' }
341
+ }
342
+ if (stat.isDirectory()) {
343
+ return { ok: false, code: 'IS_DIR', message: '这是一个目录' }
344
+ }
345
+ if (stat.size > MAX_UNTRACKED_PREVIEW) {
346
+ return { ok: true, content: null, binary: false, tooLarge: true, size: stat.size }
347
+ }
348
+ try {
349
+ const buffer = fs.readFileSync(absolute)
350
+ const probe = buffer.subarray(0, Math.min(buffer.length, 8192))
351
+ if (probe.includes(0)) {
352
+ return { ok: true, content: null, binary: true, tooLarge: false, size: stat.size }
353
+ }
354
+ return { ok: true, content: buffer.toString('utf8'), binary: false, tooLarge: false, size: stat.size }
355
+ } catch (error) {
356
+ return { ok: false, code: GIT_ERROR_CODES.GIT, message: `读取失败: ${String(error?.message ?? error)}` }
357
+ }
358
+ }
359
+
360
+ async untrackedFileDiff(root, filePath) {
361
+ const preview = this.readFilePreview(root, filePath)
362
+ if (!preview.ok) return preview
363
+ if (preview.tooLarge) {
364
+ return {
365
+ ok: true,
366
+ diff: {
367
+ files: [{ newPath: filePath, binary: false, newFile: true, hunks: [], tooLarge: true, size: preview.size }],
368
+ },
369
+ }
370
+ }
371
+ if (preview.binary) {
372
+ return { ok: true, diff: { files: [{ newPath: filePath, binary: true, newFile: true, hunks: [], size: preview.size }] } }
373
+ }
374
+ const lines = preview.content.split('\n')
375
+ const hasTrailingNewline = lines.length > 1 && lines[lines.length - 1] === ''
376
+ if (hasTrailingNewline) lines.pop()
377
+ const hunkLines = lines.map((text, i) => ({ type: 'add', text, newLine: i + 1, newline: i < lines.length - 1 || hasTrailingNewline }))
378
+ return {
379
+ ok: true,
380
+ diff: {
381
+ files: [{
382
+ newPath: filePath, binary: false, newFile: true,
383
+ hunks: [{ oldStart: 0, oldCount: 0, newStart: 1, newCount: lines.length, lines: hunkLines }],
384
+ }],
385
+ },
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Commit history.
391
+ * @param {number} limit 1..500
392
+ * @param {string|null} path optional file filter
393
+ */
394
+ async log(cwd, limit, path, signal) {
395
+ return this.guard(async () => {
396
+ const root = await this.requireRoot(cwd)
397
+ const n = Math.max(1, Math.min(Number(limit) || 100, 500))
398
+ const args = ['log', `--max-count=${n}`, '--pretty=format:%H%x00%P%x00%an%x00%ae%x00%at%x00%s%x00%D%x1e']
399
+ if (typeof path === 'string' && path !== '' && !path.includes('\0') && !path.startsWith('-')) {
400
+ args.push('--', path)
401
+ }
402
+ try {
403
+ const { stdout } = await gitRead(this.ctx, root, args, { signal })
404
+ return { ok: true, commits: parseLog(stdout) }
405
+ } catch (error) {
406
+ if (error instanceof GitError && error.code === GIT_ERROR_CODES.NO_COMMITS) {
407
+ return { ok: true, commits: [] }
408
+ }
409
+ throw error
410
+ }
411
+ })
412
+ }
413
+
414
+ /** Local + remote refs, remotes list. */
415
+ async branches(cwd, signal) {
416
+ return this.guard(async () => {
417
+ const root = await this.requireRoot(cwd)
418
+ const format = '%(refname:short)%00%(objectname:short)%00%(upstream:short)%00%(upstream:track)%00%(HEAD)%00%(subject)'
419
+ const { stdout } = await gitRead(this.ctx, root, ['for-each-ref', `--format=${format}`, '--sort=-committerdate', 'refs/heads', 'refs/remotes'], { signal })
420
+ let remotes = []
421
+ try {
422
+ const { stdout: remoteOut } = await gitRead(this.ctx, root, ['remote', '-v'], { signal })
423
+ remotes = parseRemotes(remoteOut)
424
+ } catch { /* no remotes is fine */ }
425
+ return { ok: true, refs: parseRefs(stdout), remotes }
426
+ })
427
+ }
428
+
429
+ /** user.name / user.email resolution for the commit box. */
430
+ async identity(cwd, signal) {
431
+ return this.guard(async () => {
432
+ const root = await this.requireRoot(cwd)
433
+ let name
434
+ let email
435
+ try {
436
+ name = (await gitRead(this.ctx, root, ['config', '--get', 'user.name'], { signal })).stdout.trim()
437
+ } catch { name = '' }
438
+ try {
439
+ email = (await gitRead(this.ctx, root, ['config', '--get', 'user.email'], { signal })).stdout.trim()
440
+ } catch { email = '' }
441
+ return { ok: true, name: name || undefined, email: email || undefined, hasIdentity: name !== '' && email !== '' }
442
+ })
443
+ }
444
+
445
+ /** Agent activity timeline for this workspace. */
446
+ async activity(cwd, limit, signal) {
447
+ const entries = this.tracker.list(cwd, limit)
448
+ return { ok: true, entries }
449
+ }
450
+
451
+ /**
452
+ * Workspace file inventory with per-file git state:
453
+ * `clean` | `modified` (changed vs HEAD, incl. staged) | `untracked`.
454
+ * Tracked files come from `ls-files --cached`; untracked/ignored semantics
455
+ * come from the same porcelain status the Changes view uses (its `?`
456
+ * records ARE `ls-files --others --exclude-standard`), so two git
457
+ * invocations cover everything. Directories are synthesized by the client
458
+ * from path segments.
459
+ */
460
+ async tree(cwd, signal) {
461
+ return this.guard(async () => {
462
+ const root = await this.requireRoot(cwd)
463
+ const tracked = (await gitRead(this.ctx, root, ['ls-files', '--cached', '-z'], { signal })).stdout
464
+ const statusRaw = (await gitRead(this.ctx, root, ['status', '--porcelain=v2', '-z'], { signal })).stdout
465
+ const status = parseStatusPorcelainV2(statusRaw)
466
+
467
+ const stateByPath = new Map()
468
+ const untracked = new Set()
469
+ for (const file of status.files) {
470
+ if (file.x === '!' || file.y === '!') continue
471
+ if (file.x === '?' || file.y === '?') {
472
+ stateByPath.set(file.path, 'untracked')
473
+ untracked.add(file.path)
474
+ } else {
475
+ stateByPath.set(file.path, 'modified')
476
+ }
477
+ }
478
+ const seen = new Set()
479
+ const files = []
480
+ for (const raw of `${tracked}\0${[...untracked].join('\0')}`.split('\0')) {
481
+ const filePath = raw.trim()
482
+ if (filePath === '') continue
483
+ if (seen.has(filePath)) continue
484
+ seen.add(filePath)
485
+ files.push({
486
+ path: filePath,
487
+ state: stateByPath.get(filePath) ?? 'clean',
488
+ })
489
+ }
490
+ files.sort((a, b) => a.path.localeCompare(b.path))
491
+ const MAX_TREE_FILES = 3000
492
+ const truncated = files.length > MAX_TREE_FILES
493
+ if (truncated) files.length = MAX_TREE_FILES
494
+ return { ok: true, files, truncated, total: files.length }
495
+ })
496
+ }
497
+
498
+ /**
499
+ * Read a workspace file for the tree view (clean files have no diff).
500
+ * @returns {{ok:true, content:string|null, binary:boolean, tooLarge:boolean, size:number} | {ok:false,...}}
501
+ */
502
+ async cat(cwd, path, signal) {
503
+ return this.guard(async () => {
504
+ const root = await this.requireRoot(cwd)
505
+ if (typeof path !== 'string' || path === '' || path.includes('\0') || path.startsWith('-')) {
506
+ return { ok: false, code: GIT_ERROR_CODES.NOT_FOUND, message: '非法路径' }
507
+ }
508
+ return this.readFilePreview(root, path)
509
+ })
510
+ }
511
+
512
+ // ── mutating surface (serialized per workspace by the runner) ─────────────
513
+
514
+ /** `git add -- <paths>` */
515
+ async stage(cwd, paths, signal) {
516
+ const checked = sanitizePaths(paths)
517
+ if (!checked.ok) return { ok: false, code: 'INVALID', message: checked.message }
518
+ return this.guard(async () => {
519
+ const root = await this.requireRoot(cwd)
520
+ await gitWrite(this.ctx, root, ['add', '--', ...checked.paths], { signal })
521
+ return { ok: true }
522
+ })
523
+ }
524
+
525
+ /** `git restore --staged -- <paths>` */
526
+ async unstage(cwd, paths, signal) {
527
+ const checked = sanitizePaths(paths)
528
+ if (!checked.ok) return { ok: false, code: 'INVALID', message: checked.message }
529
+ return this.guard(async () => {
530
+ const root = await this.requireRoot(cwd)
531
+ await gitWrite(this.ctx, root, ['restore', '--staged', '--', ...checked.paths], { signal })
532
+ return { ok: true }
533
+ })
534
+ }
535
+
536
+ /**
537
+ * Discard worktree changes (`git restore --`) or delete untracked files
538
+ * (`git clean -f -d --`).
539
+ */
540
+ async discard(cwd, paths, untracked, signal) {
541
+ const checked = sanitizePaths(paths)
542
+ if (!checked.ok) return { ok: false, code: 'INVALID', message: checked.message }
543
+ return this.guard(async () => {
544
+ const root = await this.requireRoot(cwd)
545
+ if (untracked === true) {
546
+ await gitWrite(this.ctx, root, ['clean', '-f', '-d', '--', ...checked.paths], { signal })
547
+ } else {
548
+ await gitWrite(this.ctx, root, ['restore', '--', ...checked.paths], { signal })
549
+ }
550
+ return { ok: true }
551
+ })
552
+ }
553
+
554
+ /** Commit staged changes with the given message. */
555
+ async commit(cwd, message, signal) {
556
+ if (typeof message !== 'string' || message.trim() === '') {
557
+ return { ok: false, code: 'INVALID', message: '提交信息不能为空' }
558
+ }
559
+ if (message.length > MAX_MESSAGE) {
560
+ return { ok: false, code: 'INVALID', message: '提交信息过长' }
561
+ }
562
+ return this.guard(async () => {
563
+ const root = await this.requireRoot(cwd)
564
+ const result = await gitWrite(this.ctx, root, ['commit', '-F', '-'], { signal, input: message, timeoutMs: 120_000 })
565
+ let hash
566
+ try {
567
+ hash = (await gitRead(this.ctx, root, ['rev-parse', 'HEAD'], { signal })).stdout.trim()
568
+ } catch { hash = undefined }
569
+ const output = (result.stdout + result.stderr).trim()
570
+ return { ok: true, hash, output }
571
+ })
572
+ }
573
+
574
+ /**
575
+ * Generate a commit message using the LLM based on staged changes.
576
+ * Reads `git diff --cached` and asks the model to produce a concise
577
+ * conventional-commit message.
578
+ */
579
+ async generateCommitMessage(cwd, signal) {
580
+ return this.guard(async () => {
581
+ const root = await this.requireRoot(cwd)
582
+
583
+ // 1. Collect staged diff (limit to avoid overwhelming the model)
584
+ const MAX_DIFF_BYTES = 60 * 1024
585
+ let diffResult
586
+ try {
587
+ diffResult = await gitRead(this.ctx, root, [
588
+ 'diff', '--cached', '--no-color', '--no-ext-diff',
589
+ '--src-prefix=a/', '--dst-prefix=b/',
590
+ ], { signal, timeoutMs: 30_000 })
591
+ } catch (error) {
592
+ if (error instanceof GitError && error.code === GIT_ERROR_CODES.NO_COMMITS) {
593
+ // Empty repo: diff against empty tree
594
+ diffResult = await gitRead(this.ctx, root, [
595
+ 'diff', '--cached', '--no-color', '--no-ext-diff',
596
+ '--src-prefix=a/', '--dst-prefix=b/',
597
+ '4b825dc642cb6eb9a060e54bf8d69288fbee4904',
598
+ ], { signal, timeoutMs: 30_000 })
599
+ } else {
600
+ throw error
601
+ }
602
+ }
603
+
604
+ let diffText = diffResult.stdout
605
+ if (Buffer.byteLength(diffText, 'utf8') > MAX_DIFF_BYTES) {
606
+ diffText = diffText.slice(0, MAX_DIFF_BYTES) + '\n... [diff truncated]'
607
+ }
608
+
609
+ if (diffText.trim() === '') {
610
+ return { ok: false, code: 'INVALID', message: '没有已暂存的改动,无法生成提交信息' }
611
+ }
612
+
613
+ // 2. Resolve model route
614
+ const llm = this.ctx.get('llm')
615
+ if (!llm) {
616
+ return { ok: false, code: 'INVALID', message: 'LLM 服务不可用' }
617
+ }
618
+
619
+ let provider, model
620
+ const defaultModel = this.ctx.get('agentDefaultModel')
621
+ if (defaultModel) {
622
+ const sel = defaultModel.currentSelection()
623
+ provider = sel.provider
624
+ model = sel.model
625
+ }
626
+ if (!provider || !model) {
627
+ return { ok: false, code: 'INVALID', message: '未配置默认模型,无法生成提交信息' }
628
+ }
629
+
630
+ // 3. Build prompt
631
+ const system = [
632
+ 'You are a helpful assistant that generates concise, high-quality Git commit messages.',
633
+ 'Rules:',
634
+ '- Use Conventional Commits format when possible (e.g. feat: ..., fix: ..., refactor: ...)',
635
+ '- Write the subject line (first line) under 72 characters',
636
+ '- Use the imperative mood ("add" not "added")',
637
+ '- If a body is needed, add a blank line after the subject, then wrap at 72 characters',
638
+ '- Use the same language as the code changes (usually English, but match Chinese comments if present)',
639
+ '- Return ONLY the commit message, no explanations, no markdown fences, no prefix',
640
+ ].join('\n')
641
+
642
+ const userText = [
643
+ 'Based on the following staged diff, generate a single Git commit message.',
644
+ '',
645
+ '```diff',
646
+ diffText,
647
+ '```',
648
+ ].join('\n')
649
+
650
+ const messages = [createUserMessage({
651
+ content: [{ type: 'text', text: userText }],
652
+ source: { kind: 'plugin', plugin: 'dsh-git-gui' },
653
+ })]
654
+
655
+ // 4. Call LLM
656
+ // 提交信息本身很短,但推理型模型会把大量输出额度消耗在思考上(实测可
657
+ // 吃掉整个预算仍未输出正文)。先用常规预算试;finish=max-tokens 且无正文
658
+ // 时用更大预算重试一次。非推理模型第一次就会直接出正文,不受影响。
659
+ const BUDGETS = [2048, 8192]
660
+ let finish = null
661
+ let blocks = []
662
+ let usage = null
663
+ let text = ''
664
+ for (const maxTokens of BUDGETS) {
665
+ const assembler = new BlockAssembler()
666
+ for await (const chunk of llm.stream({
667
+ provider,
668
+ model,
669
+ messages,
670
+ system,
671
+ maxTokens,
672
+ signal,
673
+ })) {
674
+ if (signal?.aborted) break
675
+ assembler.push(chunk)
676
+ }
677
+ finish = assembler.finish
678
+ usage = assembler.usage
679
+ blocks = assembler.blocks()
680
+ text = blocks
681
+ .filter((b) => b.type === 'text')
682
+ .map((b) => b.text)
683
+ .join('')
684
+ .trim()
685
+ if (finish.kind === 'error' || finish.kind === 'aborted' || text) break
686
+ }
687
+
688
+ if (finish.kind === 'error' || finish.kind === 'aborted') {
689
+ return { ok: false, code: 'LLM_ERROR', message: finish.failure?.message ?? '模型调用失败' }
690
+ }
691
+
692
+ if (!text) {
693
+ const usageText = usage
694
+ ? `输入${usage.inputTokens ?? '?'}/输出${usage.outputTokens ?? '?'}tokens`
695
+ : '无 usage'
696
+ const kinds = blocks.map((b) => b.type).join(',') || '(无内容块)'
697
+ const hint = finish.kind === 'max-tokens'
698
+ ? ';模型把输出额度全部用在了推理上,已用更大额度重试仍无正文——请换非推理模型,或在供应商/模型配置侧关闭思考'
699
+ : ''
700
+ return {
701
+ ok: false,
702
+ code: 'LLM_ERROR',
703
+ message: `模型未返回有效的提交信息(finish=${finish.kind},块类型:${kinds},${usageText}${hint})`,
704
+ }
705
+ }
706
+
707
+ // 5. Clean up: strip markdown fences if the model wrapped them anyway
708
+ const cleaned = text
709
+ .replace(/^```(?:commit|git|plaintext)?\n?/gm, '')
710
+ .replace(/\n?```$/gm, '')
711
+ .trim()
712
+
713
+ return { ok: true, message: cleaned }
714
+ })
715
+ }
716
+
717
+ /** Create / switch branch. */
718
+ async switchBranch(cwd, name, create, signal) {
719
+ const checked = sanitizeBranch(name)
720
+ if (!checked.ok) return { ok: false, code: 'INVALID', message: checked.message }
721
+ return this.guard(async () => {
722
+ const root = await this.requireRoot(cwd)
723
+ const args = create === true ? ['switch', '-c', checked.name] : ['switch', checked.name]
724
+ const result = await gitWrite(this.ctx, root, args, { signal, timeoutMs: 120_000 })
725
+ return { ok: true, output: (result.stdout + result.stderr).trim() }
726
+ })
727
+ }
728
+
729
+ /** Merge a ref into the current branch. */
730
+ async merge(cwd, ref, signal) {
731
+ const checked = sanitizeCommit(ref)
732
+ if (!checked.ok) {
733
+ const branchChecked = sanitizeBranch(ref)
734
+ if (!branchChecked.ok) return { ok: false, code: 'INVALID', message: checked.message }
735
+ }
736
+ return this.guard(async () => {
737
+ const root = await this.requireRoot(cwd)
738
+ const result = await gitWrite(this.ctx, root, ['merge', '-m', `Merge '${ref}'`, ref], { signal, timeoutMs: 180_000 })
739
+ return { ok: true, output: (result.stdout + result.stderr).trim() }
740
+ })
741
+ }
742
+
743
+ /** pull with a selected strategy. */
744
+ async pull(cwd, mode, signal) {
745
+ if (mode !== 'ff-only' && mode !== 'merge' && mode !== 'rebase') {
746
+ return { ok: false, code: 'INVALID', message: '非法 pull 模式' }
747
+ }
748
+ const flag = mode === 'ff-only' ? '--ff-only' : mode === 'rebase' ? '--rebase' : '--no-rebase'
749
+ return this.guard(async () => {
750
+ const root = await this.requireRoot(cwd)
751
+ const result = await gitWrite(this.ctx, root, ['pull', flag], { signal, timeoutMs: 300_000, remote: true })
752
+ return { ok: true, output: (result.stdout + result.stderr).trim() }
753
+ })
754
+ }
755
+
756
+ /**
757
+ * Push to the configured upstream; when the branch has no upstream yet
758
+ * (fresh clone / first push), fall back to `git push -u <remote> <branch>`.
759
+ */
760
+ async push(cwd, signal) {
761
+ return this.guard(async () => {
762
+ const root = await this.requireRoot(cwd)
763
+ let result
764
+ try {
765
+ result = await gitWrite(this.ctx, root, ['push'], { signal, timeoutMs: 300_000, remote: true })
766
+ } catch (error) {
767
+ if (!(error instanceof GitError) || error.code !== GIT_ERROR_CODES.NO_UPSTREAM) throw error
768
+ const remote = await this.firstRemote(root, signal)
769
+ const branch = await this.currentBranch(root, signal)
770
+ if (remote === null || branch === null) throw error
771
+ result = await gitWrite(this.ctx, root, ['push', '-u', remote, branch], { signal, timeoutMs: 300_000, remote: true })
772
+ }
773
+ return { ok: true, output: (result.stdout + result.stderr).trim() }
774
+ })
775
+ }
776
+
777
+ /** fetch all remotes (prunes stale remote-tracking refs). */
778
+ async fetch(cwd, signal) {
779
+ return this.guard(async () => {
780
+ const root = await this.requireRoot(cwd)
781
+ const result = await gitWrite(this.ctx, root, ['fetch', '--all', '--prune'], { signal, timeoutMs: 300_000, remote: true })
782
+ return { ok: true, output: (result.stdout + result.stderr).trim() }
783
+ })
784
+ }
785
+
786
+ /** Remotes configured for the workspace. */
787
+ async remoteList(cwd, signal) {
788
+ return this.guard(async () => {
789
+ const root = await this.requireRoot(cwd)
790
+ try {
791
+ const { stdout } = await gitRead(this.ctx, root, ['remote', '-v'], { signal })
792
+ return { ok: true, remotes: parseRemotes(stdout) }
793
+ } catch (error) {
794
+ if (error instanceof GitError && error.code === GIT_ERROR_CODES.NOT_REPO) throw error
795
+ return { ok: true, remotes: [] }
796
+ }
797
+ })
798
+ }
799
+
800
+ /** Add a remote (name + url). */
801
+ async remoteAdd(cwd, name, url, signal) {
802
+ if (typeof name !== 'string' || !/^[A-Za-z0-9._-]{1,64}$/.test(name)) {
803
+ return { ok: false, code: 'INVALID', message: '远程名称只能包含字母、数字、. _ - (1-64 字符)' }
804
+ }
805
+ if (typeof url !== 'string' || url === '' || url.length > 500 || /[\s\x00-\x1f]/.test(url) || url.startsWith('-')) {
806
+ return { ok: false, code: 'INVALID', message: '远程 URL 无效' }
807
+ }
808
+ return this.guard(async () => {
809
+ const root = await this.requireRoot(cwd)
810
+ const result = await gitWrite(this.ctx, root, ['remote', 'add', name, url], { signal })
811
+ return { ok: true, output: (result.stdout + result.stderr).trim() }
812
+ })
813
+ }
814
+
815
+ async firstRemote(root, signal) {
816
+ try {
817
+ const { stdout } = await gitRead(this.ctx, root, ['remote'], { signal })
818
+ const name = stdout.split('\n').map((l) => l.trim()).find((l) => l !== '')
819
+ return name ?? null
820
+ } catch {
821
+ return null
822
+ }
823
+ }
824
+
825
+ async currentBranch(root, signal) {
826
+ try {
827
+ const { stdout } = await gitRead(this.ctx, root, ['branch', '--show-current'], { signal })
828
+ const name = stdout.trim()
829
+ return name === '' ? null : name
830
+ } catch {
831
+ return null
832
+ }
833
+ }
834
+
835
+ /** stash: list / push / pop / apply / drop. */
836
+ async stash(cwd, op, message, stashId, signal) {
837
+ return this.guard(async () => {
838
+ const root = await this.requireRoot(cwd)
839
+ if (op === 'list') {
840
+ try {
841
+ const { stdout } = await gitRead(this.ctx, root, ['stash', 'list', '--pretty=format:%gd%x00%H%x00%s'], { signal })
842
+ return { ok: true, stashes: parseStashList(stdout) }
843
+ } catch (error) {
844
+ if (error instanceof GitError && error.code === GIT_ERROR_CODES.NO_COMMITS) {
845
+ return { ok: true, stashes: [] }
846
+ }
847
+ throw error
848
+ }
849
+ }
850
+ let args
851
+ if (op === 'push') {
852
+ args = ['stash', 'push']
853
+ if (typeof message === 'string' && message.trim() !== '') args.push('-m', message.trim().slice(0, 1000))
854
+ } else if (op === 'pop' || op === 'apply' || op === 'drop') {
855
+ const checked = sanitizeStashRef(stashId)
856
+ if (!checked.ok) return { ok: false, code: 'INVALID', message: checked.message }
857
+ args = ['stash', op, checked.ref]
858
+ } else {
859
+ return { ok: false, code: 'INVALID', message: '非法 stash 操作' }
860
+ }
861
+ const result = await gitWrite(this.ctx, root, args, { signal, timeoutMs: 120_000 })
862
+ return { ok: true, output: (result.stdout + result.stderr).trim() }
863
+ })
864
+ }
865
+
866
+ /** Revert a commit (non-interactive). */
867
+ async revert(cwd, commit, signal) {
868
+ const checked = sanitizeCommit(commit)
869
+ if (!checked.ok) return { ok: false, code: 'INVALID', message: checked.message }
870
+ return this.guard(async () => {
871
+ const root = await this.requireRoot(cwd)
872
+ const result = await gitWrite(this.ctx, root, ['revert', '--no-edit', checked.commit], { signal, timeoutMs: 180_000 })
873
+ return { ok: true, output: (result.stdout + result.stderr).trim() }
874
+ })
875
+ }
876
+
877
+ /** reset soft/mixed/hard, optional target (default HEAD). */
878
+ async reset(cwd, mode, target, signal) {
879
+ if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
880
+ return { ok: false, code: 'INVALID', message: '非法 reset 模式' }
881
+ }
882
+ if (typeof target !== 'string' || target === '') target = 'HEAD'
883
+ const checked = sanitizeCommit(target, true)
884
+ if (!checked.ok) return { ok: false, code: 'INVALID', message: checked.message }
885
+ return this.guard(async () => {
886
+ const root = await this.requireRoot(cwd)
887
+ const result = await gitWrite(this.ctx, root, ['reset', `--${mode}`, checked.commit], { signal, timeoutMs: 120_000 })
888
+ return { ok: true, output: (result.stdout + result.stderr).trim() }
889
+ })
890
+ }
891
+
892
+ /**
893
+ * Initialize a repository at the session workspace root (this is the only
894
+ * mutation that runs at `cwd` instead of a discovered root — creating a
895
+ * repo under a random nested directory would surprise the user).
896
+ */
897
+ async init(cwd, signal) {
898
+ return this.guard(async () => {
899
+ const result = await gitWrite(this.ctx, cwd, ['init'], { signal })
900
+ // drop the cached "not a repo" discovery so the next check finds it
901
+ this.rootCache.delete(path.resolve(cwd).toLowerCase())
902
+ return { ok: true, output: (result.stdout + result.stderr).trim() }
903
+ })
904
+ }
905
+ }
906
+
907
+ // Register every public business method for Typert source-mode discovery.
908
+ markRemote(GitService.prototype, 'check')
909
+ markRemote(GitService.prototype, 'status')
910
+ markRemote(GitService.prototype, 'diff')
911
+ markRemote(GitService.prototype, 'log')
912
+ markRemote(GitService.prototype, 'branches')
913
+ markRemote(GitService.prototype, 'identity')
914
+ markRemote(GitService.prototype, 'activity')
915
+ markRemote(GitService.prototype, 'tree')
916
+ markRemote(GitService.prototype, 'cat')
917
+ markRemote(GitService.prototype, 'stage')
918
+ markRemote(GitService.prototype, 'unstage')
919
+ markRemote(GitService.prototype, 'discard')
920
+ markRemote(GitService.prototype, 'commit')
921
+ markRemote(GitService.prototype, 'generateCommitMessage')
922
+ markRemote(GitService.prototype, 'switchBranch')
923
+ markRemote(GitService.prototype, 'merge')
924
+ markRemote(GitService.prototype, 'pull')
925
+ markRemote(GitService.prototype, 'push')
926
+ markRemote(GitService.prototype, 'fetch')
927
+ markRemote(GitService.prototype, 'remoteList')
928
+ markRemote(GitService.prototype, 'remoteAdd')
929
+ markRemote(GitService.prototype, 'stash')
930
+ markRemote(GitService.prototype, 'revert')
931
+ markRemote(GitService.prototype, 'reset')
932
+ markRemote(GitService.prototype, 'init')