@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/LICENSE +21 -0
- package/README.md +110 -0
- package/cordis.patch.example.yml +6 -0
- package/cordis.patch.yml +10 -0
- package/lib/activity.js +140 -0
- package/lib/client.js +2222 -0
- package/lib/index.js +24 -0
- package/lib/parse.js +298 -0
- package/lib/runner.js +294 -0
- package/lib/service.js +932 -0
- package/package.json +68 -0
- package/scripts/build-client.mjs +110 -0
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')
|