@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/index.js ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * dsh-git-gui — Host plugin entry.
3
+ *
4
+ * A zero-dependency dual-face package:
5
+ * - this file is the Node (host) half, loaded as an ordinary cordis plugin
6
+ * row from the profile's `cordis.patch.yml`(bundle 补丁层自动注册);
7
+ * - `lib/client.js` is the browser half, built by `scripts/build-client.mjs`
8
+ * into the `window.__ModuleLoader__.load` CJS shape the web module system
9
+ * serves under `/plugins/<package name>/client.js`.
10
+ *
11
+ * The host half provides one Typert service (`gitService`, wire namespace
12
+ * `git`) whose `@Remote` methods become `git/*` endpoints for the browser,
13
+ * plus an ActivityTracker that attributes agent file mutations to
14
+ * (session, turn, tool) for the "AI 修改时间线" view.
15
+ */
16
+
17
+ import { GitService } from './service.js'
18
+ import { ActivityTracker } from './activity.js'
19
+
20
+ /** @type {import('@deepseek-ai/cordis').Plugin.Function} */
21
+ export default function gitGuiPlugin(ctx) {
22
+ const tracker = new ActivityTracker(ctx)
23
+ new GitService(ctx, tracker)
24
+ }
package/lib/parse.js ADDED
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Pure parsers for git plumbing output (zero dependencies).
3
+ *
4
+ * Conventions followed throughout:
5
+ * - every git invocation uses NUL / custom separators (-z, --format with %x00),
6
+ * so paths with spaces, quotes, CJK and newlines parse unambiguously;
7
+ * - `core.quotepath=false` keeps paths verbatim (UTF-8) on the wire;
8
+ * - parsers never throw on malformed input: they degrade to empty/partial
9
+ * results so one weird repo cannot take the panel down.
10
+ */
11
+
12
+ /**
13
+ * Parse `git status --porcelain=v2 -z --branch` output.
14
+ *
15
+ * Records are NUL-separated. v2 record shapes:
16
+ * `1 XY sub mH mI mW hH hI path`
17
+ * `2 XY sub mH mI mW hH hI Xscore path\0origPath`
18
+ * `u XY sub m1 m2 m3 mW h1 h2 h3 path`
19
+ * `? path`
20
+ * `! path` (ignored)
21
+ * Branch headers: `# branch.oid <sha|(initial)>`, `# branch.head <name|(detached)>`,
22
+ * `# branch.upstream <name>`, `# branch.ab +<a> -<b>`.
23
+ *
24
+ * @param {string} raw raw stdout of the status command
25
+ * @returns {{ branch: { head?: string, oid?: string, upstream?: string, ahead?: number, behind?: number, detached: boolean, noCommits: boolean }, files: Array<{ x: string, y: string, path: string, origPath?: string, sub: string }> }}
26
+ */
27
+ export function parseStatusPorcelainV2(raw) {
28
+ const branch = { detached: false, noCommits: false }
29
+ const files = []
30
+ const tokens = raw.split('\0')
31
+ for (let i = 0; i < tokens.length; i++) {
32
+ const token = tokens[i]
33
+ if (token === '') continue
34
+ if (token.startsWith('#')) {
35
+ // header shape: `# branch.oid <value>` — the key is the first two
36
+ // space-separated segments (`# branch.oid` itself contains a space).
37
+ const m = /^(#\s+\S+)\s?(.*)$/.exec(token)
38
+ if (!m) continue
39
+ const key = m[1]
40
+ const value = m[2]
41
+ if (key === '# branch.oid') {
42
+ branch.oid = value
43
+ branch.noCommits = value === '(initial)'
44
+ } else if (key === '# branch.head') {
45
+ if (value === '(detached)') {
46
+ branch.detached = true
47
+ } else {
48
+ branch.head = value
49
+ }
50
+ } else if (key === '# branch.upstream') {
51
+ branch.upstream = value
52
+ } else if (key === '# branch.ab') {
53
+ const ab = /^\+(\d+) -(\d+)$/.exec(value)
54
+ if (ab) {
55
+ branch.ahead = Number(ab[1])
56
+ branch.behind = Number(ab[2])
57
+ }
58
+ }
59
+ continue
60
+ }
61
+ if (token.startsWith('? ') || token.startsWith('! ')) {
62
+ // untracked / ignored: `? <path>` in one NUL-terminated record
63
+ files.push({ x: token[0], y: token[0], sub: '', path: token.slice(2) })
64
+ continue
65
+ }
66
+ const fields = token.split(' ')
67
+ const marker = fields[0]
68
+ // paths may contain spaces: everything after the fixed-width header is the path
69
+ if (marker === '1' && fields.length >= 9) {
70
+ files.push({ x: fields[1][0], y: fields[1][1], sub: fields[2], path: fields.slice(8).join(' ') })
71
+ } else if (marker === '2' && fields.length >= 10) {
72
+ const path = fields.slice(9).join(' ')
73
+ const origPath = tokens[++i]
74
+ files.push({ x: fields[1][0], y: fields[1][1], sub: fields[2], path, origPath })
75
+ } else if (marker === 'u' && fields.length >= 11) {
76
+ files.push({ x: fields[1][0], y: fields[1][1], sub: fields[2], path: fields.slice(10).join(' ') })
77
+ }
78
+ // anything else: unknown record shape, skip defensively
79
+ }
80
+ return { branch, files }
81
+ }
82
+
83
+ /** Sort status files: staged/unstaged first by path, untracked last. */
84
+ export function sortStatusFiles(files) {
85
+ const rank = (f) => {
86
+ if (f.x === '?') return 3
87
+ if (f.x === 'U' || f.y === 'U') return 2
88
+ return f.x === '.' ? 1 : 0
89
+ }
90
+ return [...files].sort((a, b) => rank(a) - rank(b) || a.path.localeCompare(b.path))
91
+ }
92
+
93
+ /**
94
+ * Parse unified git diff output into structured files.
95
+ *
96
+ * @param {string} raw diff text
97
+ * @param {object} [caps]
98
+ * @param {number} [caps.maxLinesPerFile] safety cap for hunk lines
99
+ * @returns {{ files: Array<{ oldPath?: string, newPath: string, mode?: string, binary: boolean, newFile: boolean, deleted: boolean, hunks: Array<{ oldStart: number, oldCount: number, newStart: number, newCount: number, lines: Array<{ type: 'ctx'|'add'|'del', text: string, oldLine?: number, newLine?: number, newline: boolean }> }> }>, truncated: boolean }}
100
+ */
101
+ export function parseUnifiedDiff(raw, caps = {}) {
102
+ const maxLinesPerFile = caps.maxLinesPerFile ?? 12000
103
+ const lines = raw.split('\n')
104
+ const files = []
105
+ let cur = null
106
+ let truncated = false
107
+ let hunk = null
108
+ let oldLine = 0
109
+ let newLine = 0
110
+ let pendingNoNewline = null
111
+
112
+ const finishHunk = () => { hunk = null }
113
+ const finishFile = () => {
114
+ if (cur && pendingNoNewline && cur.hunks.length > 0) {
115
+ const last = cur.hunks[cur.hunks.length - 1]
116
+ if (last.lines.length > 0) last.lines[last.lines.length - 1].newline = false
117
+ }
118
+ pendingNoNewline = null
119
+ cur = null
120
+ }
121
+
122
+ for (let i = 0; i < lines.length; i++) {
123
+ const line = lines[i]
124
+ if (line.startsWith('diff --git ')) {
125
+ finishFile()
126
+ cur = { newPath: '', oldPath: undefined, mode: undefined, binary: false, newFile: false, deleted: false, hunks: [] }
127
+ const m = /^diff --git a\/(.*) b\/(.*)$/.exec(line)
128
+ if (m) {
129
+ cur.oldPath = m[1]
130
+ cur.newPath = m[2]
131
+ } else {
132
+ cur.newPath = line.slice('diff --git '.length)
133
+ }
134
+ files.push(cur)
135
+ continue
136
+ }
137
+ if (cur === null) {
138
+ if (line.startsWith('Binary files ')) {
139
+ // binary diff without a diff --git header (rare); attach to last file if any
140
+ const last = files[files.length - 1]
141
+ if (last) last.binary = true
142
+ }
143
+ continue
144
+ }
145
+ if (line.startsWith('Binary files ') || line.startsWith('GIT binary patch') || line.startsWith('cannot apply binary patch')) {
146
+ cur.binary = true
147
+ continue
148
+ }
149
+ if (line.startsWith('new file mode ')) {
150
+ cur.newFile = true
151
+ cur.mode = line.slice('new file mode '.length)
152
+ continue
153
+ }
154
+ if (line.startsWith('deleted file mode ')) {
155
+ cur.deleted = true
156
+ cur.mode = line.slice('deleted file mode '.length)
157
+ continue
158
+ }
159
+ if (line.startsWith('old mode ') || line.startsWith('new mode ')) {
160
+ continue
161
+ }
162
+ if (line.startsWith('--- ') || line.startsWith('+++ ')) {
163
+ continue
164
+ }
165
+ if (line.startsWith('@@')) {
166
+ finishHunk()
167
+ const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line)
168
+ if (m) {
169
+ hunk = {
170
+ oldStart: Number(m[1]),
171
+ oldCount: m[2] === undefined ? 1 : Number(m[2]),
172
+ newStart: Number(m[3]),
173
+ newCount: m[4] === undefined ? 1 : Number(m[4]),
174
+ lines: [],
175
+ }
176
+ cur.hunks.push(hunk)
177
+ oldLine = hunk.oldStart
178
+ newLine = hunk.newStart
179
+ if (cur.hunks.length > 400) { truncated = true; break }
180
+ }
181
+ continue
182
+ }
183
+ if (line === '\') {
184
+ pendingNoNewline = true
185
+ if (hunk && hunk.lines.length > 0) {
186
+ hunk.lines[hunk.lines.length - 1].newline = false
187
+ pendingNoNewline = null
188
+ }
189
+ continue
190
+ }
191
+ if (hunk === null) continue
192
+ if (cur.binary) continue
193
+ const first = line[0]
194
+ if (first === ' ') {
195
+ hunk.lines.push({ type: 'ctx', text: line.slice(1), oldLine: oldLine++, newLine: newLine++, newline: true })
196
+ } else if (first === '+') {
197
+ hunk.lines.push({ type: 'add', text: line.slice(1), oldLine: undefined, newLine: newLine++, newline: true })
198
+ } else if (first === '-') {
199
+ hunk.lines.push({ type: 'del', text: line.slice(1), oldLine: oldLine++, newLine: undefined, newline: true })
200
+ }
201
+ if (hunk.lines.length > maxLinesPerFile) {
202
+ hunk.lines.push({ type: 'ctx', text: '… (hunk truncated by the Git panel)', newline: true })
203
+ truncated = true
204
+ finishHunk()
205
+ }
206
+ }
207
+ finishFile()
208
+ return { files, truncated }
209
+ }
210
+
211
+ /**
212
+ * Parse `git log --pretty=format:%H%x00%P%x00%an%x00%ae%x00%at%x00%s%x00%D%x1e`.
213
+ * @param {string} raw
214
+ * @returns {Array<{ hash: string, parents: string[], author: string, email: string, time: number, subject: string, refs: string[] }>}
215
+ */
216
+ export function parseLog(raw) {
217
+ if (raw.trim() === '') return []
218
+ const commits = []
219
+ for (const record of raw.split('\x1e')) {
220
+ const fields = record.split('\0')
221
+ if (fields.length < 7) continue
222
+ const [hash, parents, author, email, time, subject, refsRaw] = fields
223
+ if (!hash) continue
224
+ commits.push({
225
+ hash,
226
+ parents: parents === '' ? [] : parents.split(' '),
227
+ author,
228
+ email,
229
+ time: Number(time) || 0,
230
+ subject: subject || '',
231
+ refs: (refsRaw || '')
232
+ .replace(/^\(|\)$/g, '')
233
+ .split(', ')
234
+ .map((r) => r.trim())
235
+ .filter((r) => r !== ''),
236
+ })
237
+ }
238
+ return commits
239
+ }
240
+
241
+ /**
242
+ * Parse `git for-each-ref` output with `%00` separators:
243
+ * `%(refname:short)%00%(objectname:short)%00%(upstream:short)%00%(upstream:track)%00%(HEAD)%00%(subject)`.
244
+ * @param {string} raw
245
+ * @returns {Array<{ name: string, short: string, upstream?: string, track?: string, current: boolean, subject?: string }>}
246
+ */
247
+ export function parseRefs(raw) {
248
+ const refs = []
249
+ for (const line of raw.split('\n')) {
250
+ if (line.trim() === '') continue
251
+ const f = line.split('\0')
252
+ if (f.length < 5) continue
253
+ const name = f[0]
254
+ if (name === '' || name === 'origin/HEAD' || name.endsWith('/HEAD')) continue
255
+ refs.push({
256
+ name,
257
+ short: f[1],
258
+ upstream: f[2] === '' ? undefined : f[2],
259
+ track: f[3] === '' ? undefined : f[3],
260
+ current: f[4] === '*',
261
+ subject: f[5],
262
+ })
263
+ }
264
+ return refs
265
+ }
266
+
267
+ /**
268
+ * Parse `git stash list` with `--pretty=format:%gd%00%H%00%s` (one stash per line).
269
+ * @param {string} raw
270
+ * @returns {Array<{ ref: string, hash: string, subject: string }>}
271
+ */
272
+ export function parseStashList(raw) {
273
+ if (raw.trim() === '') return []
274
+ const list = []
275
+ for (const line of raw.split('\n')) {
276
+ const f = line.split('\0')
277
+ if (f.length < 3) continue
278
+ list.push({ ref: f[0], hash: f[1], subject: f[2] })
279
+ }
280
+ return list
281
+ }
282
+
283
+ /**
284
+ * Parse `git remote -v` output into a unique remote list.
285
+ * @param {string} raw
286
+ * @returns {Array<{ name: string, fetch?: string, push?: string }>}
287
+ */
288
+ export function parseRemotes(raw) {
289
+ const map = new Map()
290
+ for (const line of raw.split('\n')) {
291
+ const m = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(line.trim())
292
+ if (!m) continue
293
+ if (!map.has(m[1])) map.set(m[1], { name: m[1] })
294
+ const entry = map.get(m[1])
295
+ entry[m[3]] = m[2]
296
+ }
297
+ return [...map.values()]
298
+ }
package/lib/runner.js ADDED
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Git process runner (zero dependencies).
3
+ *
4
+ * - spawns `git` with explicit argv (never a shell), `-C <cwd>` anchoring;
5
+ * - collects stdout/stderr with a byte cap (tail kept) so a runaway diff or
6
+ * log cannot exhaust host memory;
7
+ * - serializes mutating commands per workspace (git's own index.lock is the
8
+ * final arbiter, but the queue keeps UI state coherent);
9
+ * - classifies common failures into stable error codes for the client.
10
+ */
11
+
12
+ import { spawn } from 'node:child_process'
13
+ import path from 'node:path'
14
+
15
+ export const GIT_ERROR_CODES = {
16
+ NOT_REPO: 'NOT_REPO',
17
+ NO_COMMITS: 'NO_COMMITS',
18
+ LOCKED: 'LOCKED',
19
+ UNMERGED: 'UNMERGED',
20
+ CONFLICT: 'CONFLICT',
21
+ HOOK_FAILED: 'HOOK_FAILED',
22
+ IDENTITY: 'IDENTITY',
23
+ AUTH: 'AUTH',
24
+ NETWORK: 'NETWORK',
25
+ NO_UPSTREAM: 'NO_UPSTREAM',
26
+ REMOTE_EXISTS: 'REMOTE_EXISTS',
27
+ NOT_FOUND: 'NOT_FOUND',
28
+ CANCELLED: 'CANCELLED',
29
+ TIMEOUT: 'TIMEOUT',
30
+ GIT: 'GIT',
31
+ }
32
+
33
+ export class GitError extends Error {
34
+ /**
35
+ * @param {string} code one of GIT_ERROR_CODES
36
+ * @param {string} message human readable
37
+ * @param {string} [detail] raw stderr tail for diagnostics
38
+ */
39
+ constructor(code, message, detail) {
40
+ super(message)
41
+ this.name = 'GitError'
42
+ this.code = code
43
+ this.detail = detail
44
+ }
45
+ }
46
+
47
+ const MAX_STREAM_BYTES = 4 * 1024 * 1024
48
+ const TRUNCATION_MARKER = '\n\u2026 [output truncated by the Git panel]'
49
+ const READ_ONLY_COMMANDS = new Set([
50
+ 'status', 'diff', 'log', 'for-each-ref', 'show', 'rev-parse', 'config',
51
+ 'remote', 'stash', 'var', 'version', 'rev-list', 'ls-files', 'branch',
52
+ ])
53
+
54
+ /** Per-workspace FIFO queue for mutating commands. */
55
+ const queues = new Map()
56
+
57
+ /**
58
+ * Run one git command.
59
+ *
60
+ * @param {object} ctx cordis context (unused beyond logging, kept for the seam)
61
+ * @param {string} cwd workspace root (git resolves the real repo root itself)
62
+ * @param {string[]} args argv after `git` (first element = git subcommand)
63
+ * @param {object} [opts]
64
+ * @param {boolean} [opts.mutating] serialized per workspace when true
65
+ * @param {string} [opts.input] stdin text (e.g. commit message)
66
+ * @param {number} [opts.timeoutMs] deadline; default 30s
67
+ * @param {AbortSignal} [opts.signal] caller cancellation
68
+ * @param {string} [opts.gitPath] explicit git executable (settings override)
69
+ * @returns {Promise<{ stdout: string, stderr: string, exitCode: number, stdoutTruncated: boolean, stderrTruncated: boolean }>}
70
+ */
71
+ export async function runGit(ctx, cwd, args, opts = {}) {
72
+ const mutating = opts.mutating === true
73
+ const run = () => executeGit(ctx, cwd, args, opts)
74
+ if (!mutating) return run()
75
+ const key = queueKey(cwd)
76
+ const prev = queues.get(key) ?? Promise.resolve()
77
+ const next = prev.then(run, run)
78
+ queues.set(key, next.catch(() => {}))
79
+ try {
80
+ return await next
81
+ } finally {
82
+ if (queues.get(key) === next) queues.delete(key)
83
+ }
84
+ }
85
+
86
+ function queueKey(cwd) {
87
+ return path.resolve(cwd).toLowerCase()
88
+ }
89
+
90
+ async function executeGit(ctx, cwd, args, opts) {
91
+ const gitPath = opts.gitPath && opts.gitPath.trim() !== '' ? opts.gitPath : 'git'
92
+ const argv = ['-C', path.resolve(cwd), ...args]
93
+ const timeoutMs = opts.timeoutMs ?? 30_000
94
+ const isRead = READ_ONLY_COMMANDS.has(args[0] ?? '')
95
+ const env = {
96
+ ...process.env,
97
+ GIT_TERMINAL_PROMPT: '0',
98
+ LANG: 'en_US.UTF-8',
99
+ LC_ALL: 'en_US.UTF-8',
100
+ ...(isRead ? { GIT_OPTIONAL_LOCKS: '0' } : {}),
101
+ // 网络操作(ssh/https)禁交互:新主机密钥自动接受(BatchMode 下仅接受新密钥,
102
+ // 已知密钥变化仍会拒绝),口令/口令短语提示则直接失败而不是挂起。
103
+ ...(opts.remote === true ? { GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new' } : {}),
104
+ }
105
+
106
+ return await new Promise((resolve, reject) => {
107
+ let settled = false
108
+ const stdoutChunks = []
109
+ const stderrChunks = []
110
+ let stdoutBytes = 0
111
+ let stderrBytes = 0
112
+ let stdoutTruncated = false
113
+ let stderrTruncated = false
114
+ let timedOut = false
115
+
116
+ let child
117
+ try {
118
+ child = spawn(gitPath, argv, {
119
+ cwd: path.resolve(cwd),
120
+ env,
121
+ windowsHide: true,
122
+ stdio: ['pipe', 'pipe', 'pipe'],
123
+ })
124
+ } catch (error) {
125
+ reject(new GitError(GIT_ERROR_CODES.GIT, `failed to start git: ${String(error.message ?? error)}`))
126
+ return
127
+ }
128
+
129
+ const timer = setTimeout(() => {
130
+ timedOut = true
131
+ killTree(child)
132
+ }, timeoutMs)
133
+ if (timer.unref) timer.unref()
134
+
135
+ const onAbort = () => killTree(child)
136
+ if (opts.signal) {
137
+ if (opts.signal.aborted) onAbort()
138
+ else opts.signal.addEventListener('abort', onAbort, { once: true })
139
+ }
140
+
141
+ child.on('error', (error) => {
142
+ if (settled) return
143
+ settled = true
144
+ clearTimeout(timer)
145
+ if (opts.signal) opts.signal.removeEventListener('abort', onAbort)
146
+ const message = /ENOENT/.test(String(error.code)) && gitPath === 'git'
147
+ ? 'git executable not found on PATH'
148
+ : `failed to start git: ${String(error.message ?? error)}`
149
+ reject(new GitError(GIT_ERROR_CODES.GIT, message))
150
+ })
151
+
152
+ child.stdout.on('data', (chunk) => {
153
+ const keep = Math.min(chunk.length, MAX_STREAM_BYTES - stdoutBytes)
154
+ if (keep > 0) stdoutChunks.push(chunk.subarray(0, keep))
155
+ stdoutBytes += chunk.length
156
+ if (stdoutBytes > MAX_STREAM_BYTES) stdoutTruncated = true
157
+ })
158
+ child.stderr.on('data', (chunk) => {
159
+ const keep = Math.min(chunk.length, MAX_STREAM_BYTES - stderrBytes)
160
+ if (keep > 0) stderrChunks.push(chunk.subarray(0, keep))
161
+ stderrBytes += chunk.length
162
+ if (stderrBytes > MAX_STREAM_BYTES) stderrTruncated = true
163
+ })
164
+
165
+ child.on('close', (code, signal) => {
166
+ if (settled) return
167
+ settled = true
168
+ clearTimeout(timer)
169
+ if (opts.signal) opts.signal.removeEventListener('abort', onAbort)
170
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8')
171
+ const stderr = Buffer.concat(stderrChunks).toString('utf8')
172
+ if (timedOut) {
173
+ reject(new GitError(GIT_ERROR_CODES.TIMEOUT, `git ${args[0]} timed out after ${Math.round(timeoutMs / 1000)}s`, stderr))
174
+ return
175
+ }
176
+ if (opts.signal?.aborted) {
177
+ reject(new GitError(GIT_ERROR_CODES.CANCELLED, 'cancelled'))
178
+ return
179
+ }
180
+ resolve({
181
+ stdout: stdoutTruncated ? stdout + TRUNCATION_MARKER : stdout,
182
+ stderr: stderrTruncated ? stderr + TRUNCATION_MARKER : stderr,
183
+ exitCode: code ?? (signal === null ? 0 : 128),
184
+ stdoutTruncated,
185
+ stderrTruncated,
186
+ })
187
+ })
188
+
189
+ if (opts.input !== undefined && opts.input !== null) {
190
+ child.stdin.on('error', () => {})
191
+ child.stdin.end(opts.input, 'utf8')
192
+ } else {
193
+ child.stdin.end()
194
+ }
195
+ })
196
+ }
197
+
198
+ function killTree(child) {
199
+ try {
200
+ if (child && child.pid !== undefined && child.exitCode === null) {
201
+ child.kill('SIGTERM')
202
+ const force = setTimeout(() => {
203
+ try {
204
+ if (child.exitCode === null) child.kill('SIGKILL')
205
+ } catch { /* already gone */ }
206
+ }, 1500)
207
+ if (force.unref) force.unref()
208
+ }
209
+ } catch { /* already gone */ }
210
+ }
211
+
212
+ /**
213
+ * Classify a failed git exit into a stable code + message.
214
+ * @param {string} stderr
215
+ * @param {string} subcommand
216
+ * @returns {{ code: string, message: string } | null} null when unknown
217
+ */
218
+ export function classifyGitFailure(stderr, subcommand) {
219
+ const s = stderr ?? ''
220
+ const hay = s.toLowerCase()
221
+ if (/not a git repository|does not appear to be a git repository|outside repository/.test(hay)) {
222
+ return { code: GIT_ERROR_CODES.NOT_REPO, message: '当前目录不是 Git 仓库 (not a git repository)' }
223
+ }
224
+ if (/unable to create .*index\.lock|another git process seems to be running/.test(hay)) {
225
+ return { code: GIT_ERROR_CODES.LOCKED, message: 'Git 索引被锁定:另一个 Git 进程正在运行 (index.lock)' }
226
+ }
227
+ if (/your local changes to the following files would be overwritten|untracked working tree files would be overwritten/.test(hay)) {
228
+ return { code: GIT_ERROR_CODES.UNMERGED, message: '切换被阻止:本地改动会被覆盖,请先提交或暂存 (checkout conflict)' }
229
+ }
230
+ if (/automatic merge failed|fix conflicts|unmerged paths|merge conflict|conflict \(content\)|conflict \(modify\/delete\)|conflict \(rename\/delete\)|conflict \(add\/add\)/.test(hay)) {
231
+ return { code: GIT_ERROR_CODES.CONFLICT, message: '合并冲突:请解决冲突后提交 (merge conflict)' }
232
+ }
233
+ if (/please tell me who you are|user\.name|user\.email|empty ident name/.test(hay)) {
234
+ return { code: GIT_ERROR_CODES.IDENTITY, message: '未配置 Git 身份 (user.name / user.email)' }
235
+ }
236
+ if (/authentication failed|could not read username|could not read password|permission denied \(publickey\)|terminal prompts disabled|credentials/.test(hay)) {
237
+ return { code: GIT_ERROR_CODES.AUTH, message: '认证失败:请配置凭据助手或 SSH key (authentication failed)' }
238
+ }
239
+ if (/host key verification failed|could not resolve host|connection timed out|connection refused|network is unreachable|failed to connect|operation timed out/.test(hay)) {
240
+ return { code: GIT_ERROR_CODES.NETWORK, message: '网络/SSH 连接失败:请检查网络与远程地址 (network error)' }
241
+ }
242
+ if (/no upstream branch|has no upstream|no tracking information/.test(hay)) {
243
+ return { code: GIT_ERROR_CODES.NO_UPSTREAM, message: '当前分支还没有关联远程分支,请先 Push 一次建立关联' }
244
+ }
245
+ if (/remote .* already exists/.test(hay)) {
246
+ return { code: GIT_ERROR_CODES.REMOTE_EXISTS, message: '同名远程仓库已存在' }
247
+ }
248
+ if (/hook declined|pre-commit hook exited|error: cannot run .* hook/.test(hay)) {
249
+ return { code: GIT_ERROR_CODES.HOOK_FAILED, message: 'Git 钩子 (hook) 拒绝或失败' }
250
+ }
251
+ if (/does not have any commits yet|ambiguous argument 'head|bad revision 'head/.test(hay)) {
252
+ return { code: GIT_ERROR_CODES.NO_COMMITS, message: '仓库还没有任何提交 (no commits yet)' }
253
+ }
254
+ if (/unknown revision or path not in the working tree|pathspec .* did not match/.test(hay)) {
255
+ return { code: GIT_ERROR_CODES.NOT_FOUND, message: '目标不存在于工作区 (path not found)' }
256
+ }
257
+ return null
258
+ }
259
+
260
+ /**
261
+ * Convenience: run a read command and return parsed output or throw GitError.
262
+ * @returns {Promise<{ stdout: string, stderr: string }>}
263
+ */
264
+ export async function gitRead(ctx, cwd, args, opts = {}) {
265
+ const result = await runGit(ctx, cwd, args, { ...opts, mutating: false })
266
+ if (result.exitCode !== 0) {
267
+ const combined = `${result.stdout}\n${result.stderr}`
268
+ const classified = classifyGitFailure(combined, args[0])
269
+ throw new GitError(
270
+ classified?.code ?? GIT_ERROR_CODES.GIT,
271
+ classified?.message ?? `git ${args[0]} failed`,
272
+ combined.trim(),
273
+ )
274
+ }
275
+ return result
276
+ }
277
+
278
+ /**
279
+ * Convenience: run a mutating command (queued per workspace) or throw GitError.
280
+ * @returns {Promise<{ stdout: string, stderr: string }>}
281
+ */
282
+ export async function gitWrite(ctx, cwd, args, opts = {}) {
283
+ const result = await runGit(ctx, cwd, args, { ...opts, mutating: true })
284
+ if (result.exitCode !== 0) {
285
+ const combined = `${result.stdout}\n${result.stderr}`
286
+ const classified = classifyGitFailure(combined, args[0])
287
+ throw new GitError(
288
+ classified?.code ?? GIT_ERROR_CODES.GIT,
289
+ classified?.message ?? `git ${args[0]} failed`,
290
+ combined.trim(),
291
+ )
292
+ }
293
+ return result
294
+ }