@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/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
|
+
}
|