@caozeal/dsh-next-git 0.0.0-stage → 0.1.0

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/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Git Manager",
4
+ "description": "Sidebar Git panel: switch branches, stage changes, commit / push / pull, AI commit messages, commit graph and diffs."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Git 管理",
4
+ "description": "侧边栏 Git 面板:分支切换、变更暂存、提交 / 推送 / 拉取、AI 生成提交信息、提交图与 diff 查看。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,70 @@
1
1
  {
2
2
  "name": "@caozeal/dsh-next-git",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "DSH Git 管理插件:分支切换、变更暂存、提交/推送/拉取、AI 提交信息、提交图与 diff 查看",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./lib/client.js",
10
+ "./locale/*.json": "./locale/*.json",
11
+ "./icon": "./icon.svg",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "src",
17
+ "lib/client.js",
18
+ "cordis.patch.yml",
19
+ "locale/*.json",
20
+ "icon.svg",
21
+ "icon.png"
22
+ ],
23
+ "dsh": {
24
+ "bundle": {
25
+ "patch": "./cordis.patch.yml"
26
+ },
27
+ "client": {
28
+ "platform": "web",
29
+ "immediately": true,
30
+ "inject": [
31
+ "@deepseek-ai/dsh-client-ui-renderer",
32
+ "@deepseek-ai/dsh-api-session-controller",
33
+ "@deepseek-ai/dsh-client-ui-layout",
34
+ "@deepseek-ai/dsh-client-ui-conversation",
35
+ "@deepseek-ai/dsh-client-ui-sidebar-right",
36
+ "@deepseek-ai/dsh-client-locale",
37
+ "@deepseek-ai/dsh-client-ui-sidebar"
38
+ ]
39
+ }
40
+ },
41
+ "scripts": {
42
+ "test": "node --test test/*.test.mjs"
43
+ },
44
+ "license": "MIT",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/caozeal/dsh-plugins.git",
48
+ "directory": "dsh-next-git"
49
+ },
50
+ "homepage": "https://github.com/caozeal/dsh-plugins/tree/main/dsh-next-git#readme",
51
+ "bugs": {
52
+ "url": "https://github.com/caozeal/dsh-plugins/issues"
53
+ },
54
+ "keywords": [
55
+ "dsh",
56
+ "deepseek-harness",
57
+ "deepseek",
58
+ "plugin",
59
+ "bundle",
60
+ "git",
61
+ "web-ui"
62
+ ],
63
+ "author": "caozeal",
64
+ "engines": {
65
+ "node": ">=22"
66
+ },
67
+ "publishConfig": {
68
+ "access": "public"
69
+ }
70
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * git-manager — AI 提交信息生成。
3
+ *
4
+ * 流程:收集「已暂存 diff(为空则工作区 diff)→ 最近提交风格 → 调用默认模型的
5
+ * llm.stream」→ 清理成一行提交信息。任何一步不可用都退回确定性文案(fallback),
6
+ * 让面板始终能拿到一个可编辑的建议。
7
+ */
8
+ import { GitError } from './git.js'
9
+
10
+ const MAX_DIFF_CHARS = 12000
11
+ /** 单次生成的 token 预算:reasoning 模型会先消耗思考 token,200 太小容易截断。 */
12
+ const MAX_OUTPUT_TOKENS = 600
13
+
14
+ const SYSTEM_PROMPT = [
15
+ '你是一个 Git 提交信息生成器。',
16
+ '只输出一行提交信息,不要解释、不要代码块、不要引号、不要句号结尾。',
17
+ '使用 Conventional Commits 风格(如 feat:/fix:/refactor:/docs:/chore:),',
18
+ '不超过 72 个字符。跟随仓库最近提交的语言:最近提交多为中文就用中文,否则用英文。',
19
+ ].join('')
20
+
21
+ /** 从 diff 与最近提交构造 user prompt。 */
22
+ function buildPrompt({ diff, files, recent, hint }) {
23
+ const parts = []
24
+ if (recent.length > 0) {
25
+ parts.push('最近提交(仅作风格参考):', ...recent.slice(0, 8).map((line) => `- ${line}`), '')
26
+ }
27
+ if (files.length > 0) {
28
+ parts.push('变更文件:', ...files.slice(0, 60).map((file) => `- ${file}`), '')
29
+ }
30
+ if (hint) parts.push(`用户补充说明:${hint}`, '')
31
+ parts.push('待提交的 diff:', '```diff', diff.slice(0, MAX_DIFF_CHARS), '```')
32
+ return parts.join('\n')
33
+ }
34
+
35
+ /** 清理模型输出为一行提交信息。 */
36
+ export function sanitizeSubject(text) {
37
+ let value = String(text ?? '')
38
+ value = value.replace(/```[a-zA-Z]*/g, ' ')
39
+ const lines = value
40
+ .split('\n')
41
+ .map((line) => line.trim())
42
+ .filter((line) => line !== '')
43
+ let subject = lines[0] ?? ''
44
+ subject = subject.replace(/^(commit message|提交信息)\s*[::]\s*/i, '')
45
+ subject = subject.replace(/^["'`“”]+|["'`“”]+$/g, '')
46
+ subject = subject.replace(/\s+/g, ' ').trim()
47
+ if (subject.length > 100) subject = subject.slice(0, 100).trim()
48
+ return subject
49
+ }
50
+
51
+ /** 最近提交是否以中文为主。 */
52
+ function looksChinese(subjects) {
53
+ const joined = subjects.join('')
54
+ if (!joined) return false
55
+ const cjk = (joined.match(/[\u4e00-\u9fff]/g) || []).length
56
+ return cjk >= Math.max(1, Math.floor(joined.length * 0.2))
57
+ }
58
+
59
+ const CONVENTIONAL = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^()]+\))?!?\s*[::]\s*\S/i
60
+
61
+ /**
62
+ * 判断模型输出是否是一条可用的提交信息。
63
+ * 模型偶发会被 reasoning token 挤掉、只吐出 `fix:` 或半句话;这种输出比降级文案
64
+ * 更糟(用户会拿到不可提交的标题),因此宁可回退到确定性文案。
65
+ * @param {string} subject 清理后的一行文本
66
+ * @returns {boolean}
67
+ */
68
+ export function looksLikeSubject(subject) {
69
+ if (typeof subject !== 'string') return false
70
+ const value = subject.trim()
71
+ if (value.length < 8) return false
72
+ if (!CONVENTIONAL.test(value)) return false
73
+ // 以标点/连接词结尾 = 半句话。
74
+ if (/[,,、;;::((「『"'`+\-–—]$/.test(value)) return false
75
+ if (/(并|和|与|及|或|然后|以及|并且|同时)$/.test(value)) return false
76
+ const opens = (value.match(/(|\(/g) || []).length
77
+ const closes = (value.match(/)|\)/g) || []).length
78
+ if (opens !== closes) return false
79
+ return true
80
+ }
81
+
82
+ /** 确定性降级文案:根据变更文件数量生成一行。 */
83
+ export function fallbackSubject({ files, stagedCount, Chinese }) {
84
+ const count = files.length
85
+ const verb = stagedCount > 0 ? '提交' : '更新'
86
+ if (Chinese) {
87
+ if (count === 0) return 'chore: 空提交'
88
+ if (count === 1) return `chore: ${verb} ${files[0]}`
89
+ return `chore: ${verb} ${count} 个文件`
90
+ }
91
+ if (count === 0) return 'chore: empty commit'
92
+ if (count === 1) return `chore: update ${files[0]}`
93
+ return `chore: update ${count} files`
94
+ }
95
+
96
+ /**
97
+ * 生成一条提交信息。
98
+ * @param {{
99
+ * runGit: (args: string[], options?: object) => Promise<{stdout: string, stderr: string, code?: number|null}>,
100
+ * cwd: string,
101
+ * hint?: string,
102
+ * files?: string[],
103
+ * stagedCount?: number,
104
+ * signal?: AbortSignal,
105
+ * llm?: unknown,
106
+ * selection?: {provider?: string, model?: string, reasoningEffort?: string},
107
+ * }} options
108
+ * @returns {Promise<{message: string, model: string|null, fallback: boolean}>}
109
+ */
110
+ export async function generateCommitMessage(options) {
111
+ const {
112
+ runGit,
113
+ cwd,
114
+ hint = '',
115
+ signal,
116
+ llm,
117
+ selection,
118
+ files = [],
119
+ stagedCount = 0,
120
+ } = options
121
+
122
+ const staged = await runGit(['diff', '--cached', '--no-color'], { cwd, signal, allowFailure: true })
123
+ let diff = staged.stdout
124
+ if (!diff.trim()) {
125
+ const worktree = await runGit(['diff', '--no-color'], { cwd, signal, allowFailure: true })
126
+ diff = worktree.stdout
127
+ }
128
+ const recentRaw = await runGit(['log', '-8', '--pretty=format:%s'], { cwd, signal, allowFailure: true })
129
+ const recent = String(recentRaw.stdout || '').split('\n').map((line) => line.trim()).filter(Boolean)
130
+
131
+ let changedFiles = files
132
+ if (changedFiles.length === 0) {
133
+ const statusRaw = await runGit(['status', '--porcelain'], { cwd, signal, allowFailure: true })
134
+ changedFiles = String(statusRaw.stdout || '')
135
+ .split('\n')
136
+ .map((line) => line.slice(3).trim())
137
+ .filter(Boolean)
138
+ }
139
+
140
+ const chinese = looksChinese(recent)
141
+ const model = selection && selection.provider && selection.model
142
+ ? `${selection.provider}/${selection.model}`
143
+ : null
144
+
145
+ const canUseLlm = Boolean(llm && selection && selection.provider && selection.model && diff.trim())
146
+ if (!canUseLlm) {
147
+ return {
148
+ message: fallbackSubject({ files: changedFiles, stagedCount, Chinese: chinese }),
149
+ model,
150
+ fallback: true,
151
+ }
152
+ }
153
+
154
+ const prompt = buildPrompt({ diff, files: changedFiles, recent, hint })
155
+ let text = ''
156
+ try {
157
+ const stream = llm.stream({
158
+ provider: selection.provider,
159
+ model: selection.model,
160
+ reasoningEffort: selection.reasoningEffort,
161
+ system: SYSTEM_PROMPT,
162
+ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
163
+ maxTokens: MAX_OUTPUT_TOKENS,
164
+ temperature: 0.2,
165
+ signal,
166
+ })
167
+ for await (const chunk of stream) {
168
+ if (chunk.type === 'text-delta') text += chunk.text
169
+ if (chunk.type === 'finish') {
170
+ const kind = chunk.reason && chunk.reason.kind
171
+ if (kind === 'error' || kind === 'aborted') {
172
+ const failure = chunk.reason.failure
173
+ throw new GitError(`模型生成失败:${failure && failure.message ? failure.message : kind}`, {
174
+ code: 'ai-unavailable',
175
+ })
176
+ }
177
+ }
178
+ }
179
+ } catch (error) {
180
+ if (error instanceof GitError) throw error
181
+ const message = error && error.message ? error.message : String(error)
182
+ throw new GitError(`模型生成失败:${message}`, { code: 'ai-unavailable' })
183
+ }
184
+
185
+ const subject = sanitizeSubject(text)
186
+ if (!looksLikeSubject(subject)) {
187
+ return {
188
+ message: fallbackSubject({ files: changedFiles, stagedCount, Chinese: chinese }),
189
+ model,
190
+ fallback: true,
191
+ }
192
+ }
193
+ return { message: subject, model, fallback: false }
194
+ }
package/src/git.js ADDED
@@ -0,0 +1,399 @@
1
+ /**
2
+ * git-manager — git 进程执行与输出解析。
3
+ *
4
+ * 本模块只做两件事:
5
+ * 1. 提供 `cleanGitEnv` 与 `GitError`,由 Host 入口负责真正 spawn 进程;
6
+ * 2. 把 git 的文本输出解析成面板所需的纯 JSON(全部是纯函数,可单测)。
7
+ *
8
+ * 约定:所有解析函数都不抛异常(除 GitError 之外),坏行直接跳过。
9
+ */
10
+
11
+ /** 一次 git 失败(非 0 退出、超时或解析前置失败)。 */
12
+ export class GitError extends Error {
13
+ /**
14
+ * @param {string} message 人类可读信息(含 stderr 摘要)。
15
+ * @param {{code?: string, stderr?: string, stdout?: string, exitCode?: number|null}} [detail]
16
+ */
17
+ constructor(message, detail = {}) {
18
+ super(message)
19
+ this.name = 'GitError'
20
+ this.code = detail.code ?? 'git-failed'
21
+ this.stderr = detail.stderr ?? ''
22
+ this.stdout = detail.stdout ?? ''
23
+ this.exitCode = detail.exitCode ?? null
24
+ }
25
+ }
26
+
27
+ /**
28
+ * 构造子进程环境:去掉本机会话里损坏的 GIT_CONFIG_* 变量,并固定非交互行为。
29
+ *
30
+ * 注意:Host 的 subprocess provider 会把显式 env **合并**到父进程环境之上,
31
+ * 因此「删除」一个继承变量必须传 `undefined` 墓碑值(provider 文档明确支持),
32
+ * 不能只是不写这个键——否则父进程里那组损坏的 GIT_CONFIG_COUNT(有 COUNT、
33
+ * 缺 KEY)会原样带进子进程,git 直接 fatal `missing config key GIT_CONFIG_KEY_0`。
34
+ *
35
+ * @param {NodeJS.ProcessEnv} [env]
36
+ * @returns {NodeJS.ProcessEnv} 带 undefined 墓碑的环境补丁
37
+ */
38
+ export function cleanGitEnv(env = process.env) {
39
+ /** @type {NodeJS.ProcessEnv} */
40
+ const out = {}
41
+ for (const [key, value] of Object.entries(env)) {
42
+ if (key === 'GIT_CONFIG_COUNT' || /^GIT_CONFIG_(KEY|VALUE)_\d+$/.test(key)) {
43
+ out[key] = undefined
44
+ continue
45
+ }
46
+ if (value !== undefined) out[key] = value
47
+ }
48
+ // 无论父环境有什么,都显式清掉可能存在的配置注入槽位。
49
+ for (let index = 0; index < 16; index += 1) {
50
+ out[`GIT_CONFIG_KEY_${index}`] = undefined
51
+ out[`GIT_CONFIG_VALUE_${index}`] = undefined
52
+ }
53
+ out.GIT_CONFIG_COUNT = undefined
54
+ out.GIT_TERMINAL_PROMPT = '0'
55
+ out.GIT_PAGER = 'cat'
56
+ out.GIT_OPTIONAL_LOCKS = '0'
57
+ // 固定英文输出,让下面的解析不依赖 locale。
58
+ out.LC_ALL = 'C'
59
+ out.LANG = 'C'
60
+ return out
61
+ }
62
+
63
+ /** 把 stderr 压成一行短消息。 */
64
+ function brief(text, max = 2000) {
65
+ const trimmed = String(text || '').trim()
66
+ if (trimmed.length <= max) return trimmed
67
+ return `${trimmed.slice(0, max)}…`
68
+ }
69
+
70
+ /**
71
+ * 由一次 exec 结果构造 GitError。
72
+ * @param {string[]} argv
73
+ * @param {{code: number|null, stdout: string, stderr: string}} result
74
+ */
75
+ export function gitFailure(argv, result) {
76
+ const detail = [result.stdout, result.stderr].filter(Boolean).join('\n').trim()
77
+ const command = argv.join(' ')
78
+ return new GitError(
79
+ `git ${argv[0] === 'git' ? argv.slice(1).join(' ') : command} 失败:${brief(detail) || '无输出'}`,
80
+ { code: 'git-failed', stderr: result.stderr, stdout: result.stdout, exitCode: result.code },
81
+ )
82
+ }
83
+
84
+ /**
85
+ * 解析 `git status --porcelain=v1 -z --branch` 输出。
86
+ * @param {string} raw
87
+ * @returns {{branchLine: string|null, entries: Array<{x: string, y: string, path: string, oldPath: string|null}>}}
88
+ */
89
+ export function parsePorcelain(raw) {
90
+ const text = String(raw ?? '')
91
+ const parts = text.split('\0')
92
+ /** @type {string|null} */
93
+ let branchLine = null
94
+ /** @type {Array<{x: string, y: string, path: string, oldPath: string|null}>} */
95
+ const entries = []
96
+ for (let i = 0; i < parts.length; i += 1) {
97
+ const record = parts[i]
98
+ if (!record) continue
99
+ if (record.startsWith('## ')) {
100
+ branchLine = record.slice(3)
101
+ continue
102
+ }
103
+ if (record.length < 3) continue
104
+ const x = record[0]
105
+ const y = record[1]
106
+ let path = record.slice(3)
107
+ /** @type {string|null} */
108
+ let oldPath = null
109
+ // porcelain v1 -z:重命名/复制状态下,下一段记录是原路径。
110
+ if (x === 'R' || x === 'C' || y === 'R' || y === 'C') {
111
+ const next = parts[i + 1]
112
+ if (next !== undefined) {
113
+ oldPath = next
114
+ i += 1
115
+ }
116
+ }
117
+ // -z 模式下路径原样输出;去掉可能存在的尾部空格没有意义,保持原值。
118
+ entries.push({ x, y, path, oldPath })
119
+ }
120
+ return { branchLine, entries }
121
+ }
122
+
123
+ /**
124
+ * 解析 `--porcelain --branch` 的分支头。
125
+ * 例:`main...origin/main [ahead 1, behind 2]`、`HEAD (no branch)`。
126
+ * @param {string|null} line
127
+ * @returns {{branch: string|null, upstream: string|null, upstreamGone: boolean, ahead: number, behind: number, detached: boolean, noCommits: boolean}}
128
+ */
129
+ export function parseBranchHeader(line) {
130
+ const out = {
131
+ branch: null,
132
+ upstream: null,
133
+ upstreamGone: false,
134
+ ahead: 0,
135
+ behind: 0,
136
+ detached: false,
137
+ noCommits: false,
138
+ }
139
+ if (!line) return out
140
+ if (line.startsWith('HEAD (no branch)') || line.startsWith('HEAD (detached')) {
141
+ out.detached = true
142
+ return out
143
+ }
144
+ const noCommits = /^No commits yet on (.+)$/.exec(line)
145
+ if (noCommits) {
146
+ out.branch = noCommits[1]
147
+ out.noCommits = true
148
+ return out
149
+ }
150
+ if (line.startsWith('Initial commit on ')) {
151
+ out.branch = line.slice('Initial commit on '.length).trim()
152
+ out.noCommits = true
153
+ return out
154
+ }
155
+ let head = line
156
+ const bracket = line.indexOf(' [')
157
+ if (bracket !== -1) {
158
+ const track = line.slice(bracket + 2, line.lastIndexOf(']'))
159
+ head = line.slice(0, bracket)
160
+ const ahead = /ahead (\d+)/.exec(track)
161
+ const behind = /behind (\d+)/.exec(track)
162
+ out.ahead = ahead ? Number(ahead[1]) : 0
163
+ out.behind = behind ? Number(behind[1]) : 0
164
+ if (track.includes('gone')) out.upstreamGone = true
165
+ }
166
+ const dots = head.indexOf('...')
167
+ if (dots === -1) {
168
+ out.branch = head.trim() || null
169
+ return out
170
+ }
171
+ out.branch = head.slice(0, dots).trim() || null
172
+ out.upstream = head.slice(dots + 3).trim() || null
173
+ if (out.upstream === '') out.upstream = null
174
+ return out
175
+ }
176
+
177
+ /**
178
+ * 把 porcelain 条目分类成面板的四组。
179
+ * @param {Array<{x: string, y: string, path: string, oldPath?: string|null}>} entries
180
+ */
181
+ export function classifyEntries(entries) {
182
+ /** @type {{staged: Array<object>, unstaged: Array<object>, untracked: Array<object>, conflicted: Array<object>}} */
183
+ const out = { staged: [], unstaged: [], untracked: [], conflicted: [] }
184
+ for (const entry of entries) {
185
+ const { x, y } = entry
186
+ const item = {
187
+ path: entry.path,
188
+ status: x === '?' ? '?' : x,
189
+ oldPath: entry.oldPath ?? null,
190
+ }
191
+ const unmerged = x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D')
192
+ if (unmerged) {
193
+ out.conflicted.push({ ...item, status: `${x}${y}` })
194
+ continue
195
+ }
196
+ if (x === '?' && y === '?') {
197
+ out.untracked.push({ ...item, status: '?' })
198
+ continue
199
+ }
200
+ if (x !== ' ' && x !== '?') out.staged.push({ ...item, status: x })
201
+ if (y !== ' ' && y !== '?') {
202
+ out.unstaged.push({ ...item, status: y })
203
+ }
204
+ }
205
+ return out
206
+ }
207
+
208
+ /**
209
+ * 解析 `git for-each-ref` 的 `%00` 分隔记录。
210
+ * @param {string} raw
211
+ * @returns {Array<object>}
212
+ */
213
+ export function parseBranches(raw) {
214
+ /** @type {Array<object>} */
215
+ const branches = []
216
+ for (const line of String(raw ?? '').split('\n')) {
217
+ if (!line.trim()) continue
218
+ const fields = line.split('\0')
219
+ if (fields.length < 6) continue
220
+ const [refname, name, commit, upstream, track, date] = fields
221
+ const subject = fields.slice(6).join('\0').trim()
222
+ if (!refname || !name) continue
223
+ const kind = refname.startsWith('refs/heads/') ? 'local' : refname.startsWith('refs/remotes/') ? 'remote' : null
224
+ if (!kind) continue
225
+ if (kind === 'remote' && name.endsWith('/HEAD')) continue
226
+ const { ahead, behind, gone } = parseTrack(track)
227
+ branches.push({
228
+ name,
229
+ kind,
230
+ current: false,
231
+ upstream: upstream || null,
232
+ ahead,
233
+ behind,
234
+ gone,
235
+ commit: commit || '',
236
+ subject,
237
+ date: date || '',
238
+ })
239
+ }
240
+ return branches
241
+ }
242
+
243
+ /** 解析 `%(upstream:track)` 的 `[ahead 1, behind 2]` / `[gone]`。 */
244
+ export function parseTrack(track) {
245
+ const out = { ahead: 0, behind: 0, gone: false }
246
+ if (!track) return out
247
+ if (track.includes('gone')) out.gone = true
248
+ const ahead = /ahead (\d+)/.exec(track)
249
+ const behind = /behind (\d+)/.exec(track)
250
+ if (ahead) out.ahead = Number(ahead[1])
251
+ if (behind) out.behind = Number(behind[1])
252
+ return out
253
+ }
254
+
255
+ /**
256
+ * 解析 `git remote -v`。
257
+ * @param {string} raw
258
+ * @returns {Array<{name: string, url: string}>}
259
+ */
260
+ export function parseRemotes(raw) {
261
+ /** @type {Map<string, string>} */
262
+ const byName = new Map()
263
+ for (const line of String(raw ?? '').split('\n')) {
264
+ const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(line.trim())
265
+ if (!match) continue
266
+ if (!byName.has(match[1])) byName.set(match[1], match[2])
267
+ }
268
+ return [...byName.entries()].map(([name, url]) => ({ name, url }))
269
+ }
270
+
271
+ /**
272
+ * 解析 `git stash list --format=%gd%x00%s`。
273
+ * @param {string} raw
274
+ */
275
+ export function parseStashes(raw) {
276
+ /** @type {Array<{index: number, message: string}>} */
277
+ const stashes = []
278
+ for (const line of String(raw ?? '').split('\n')) {
279
+ if (!line.trim()) continue
280
+ const [ref, ...rest] = line.split('\0')
281
+ const match = /^stash@\{(\d+)\}$/.exec((ref || '').trim())
282
+ if (!match) continue
283
+ stashes.push({ index: Number(match[1]), message: rest.join('\0').trim() })
284
+ }
285
+ return stashes
286
+ }
287
+
288
+ /**
289
+ * 解析 `git log --graph --pretty=format:%H%x1f%h%x1f%an%x1f%ar%x1f%D%x1f%s` 输出。
290
+ * @param {string} raw
291
+ * @returns {Array<{graph: string, hash: string|null, full: string|null, refs: string[], author: string, date: string, subject: string}>}
292
+ */
293
+ export function parseGraph(raw) {
294
+ /** @type {Array<object>} */
295
+ const lines = []
296
+ for (const line of String(raw ?? '').split('\n')) {
297
+ if (line === '') continue
298
+ const fields = line.split('\x1f')
299
+ if (fields.length < 6) {
300
+ // 只有 graph 连接符的行:保留给 UI 当作骨架行。
301
+ lines.push({ graph: line, hash: null, full: null, refs: [], author: '', date: '', subject: '' })
302
+ continue
303
+ }
304
+ const head = fields[0]
305
+ const full = /([0-9a-f]{40})$/.exec(head)
306
+ if (!full) {
307
+ lines.push({ graph: line, hash: null, full: null, refs: [], author: '', date: '', subject: '' })
308
+ continue
309
+ }
310
+ const graph = head.slice(0, full.index)
311
+ const decorations = fields[4] ? fields[4].split(',').map((ref) => ref.trim()).filter(Boolean) : []
312
+ lines.push({
313
+ graph,
314
+ hash: fields[1] || full[1].slice(0, 7),
315
+ full: full[1],
316
+ refs: decorations,
317
+ author: fields[2] ?? '',
318
+ date: fields[3] ?? '',
319
+ subject: fields.slice(5).join('\x1f'),
320
+ })
321
+ }
322
+ return lines
323
+ }
324
+
325
+ /**
326
+ * 解析 `git log --pretty=%s` 等单行输出。
327
+ * @param {string} raw
328
+ * @returns {string[]}
329
+ */
330
+ export function parseLines(raw) {
331
+ return String(raw ?? '')
332
+ .split('\n')
333
+ .map((line) => line.trimEnd())
334
+ .filter((line) => line !== '')
335
+ }
336
+
337
+ const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/
338
+
339
+ /**
340
+ * 解析 unified diff,输出结构化 hunk(用于面板逐行渲染)。
341
+ * @param {string} raw
342
+ * @returns {{binary: boolean, additions: number, deletions: number, hunks: Array<{header: string, lines: Array<{kind: string, text: string, old?: number, new?: number}>}>}}
343
+ */
344
+ export function parseUnifiedDiff(raw) {
345
+ const text = String(raw ?? '')
346
+ /** @type {Array<{header: string, lines: Array<{kind: string, text: string, old?: number, new?: number}>}>} */
347
+ const hunks = []
348
+ let current = null
349
+ let oldLine = 0
350
+ let newLine = 0
351
+ let additions = 0
352
+ let deletions = 0
353
+ let binary = false
354
+ for (const line of text.split('\n')) {
355
+ if (line.startsWith('Binary files ') && line.endsWith(' differ')) {
356
+ binary = true
357
+ continue
358
+ }
359
+ if (line.startsWith('diff --git ')) {
360
+ current = null
361
+ continue
362
+ }
363
+ const hunk = HUNK_HEADER.exec(line)
364
+ if (hunk) {
365
+ oldLine = Number(hunk[1])
366
+ newLine = Number(hunk[3])
367
+ current = { header: line, lines: [] }
368
+ hunks.push(current)
369
+ continue
370
+ }
371
+ if (!current) continue
372
+ if (line === '') {
373
+ // hunk 内空行等价于一个空的上下文行。
374
+ current.lines.push({ kind: 'ctx', text: '', old: oldLine, new: newLine })
375
+ oldLine += 1
376
+ newLine += 1
377
+ continue
378
+ }
379
+ const marker = line[0]
380
+ if (marker === '+') {
381
+ current.lines.push({ kind: 'add', text: line.slice(1), new: newLine })
382
+ additions += 1
383
+ newLine += 1
384
+ } else if (marker === '-') {
385
+ current.lines.push({ kind: 'del', text: line.slice(1), old: oldLine })
386
+ deletions += 1
387
+ oldLine += 1
388
+ } else if (marker === ' ') {
389
+ current.lines.push({ kind: 'ctx', text: line.slice(1), old: oldLine, new: newLine })
390
+ oldLine += 1
391
+ newLine += 1
392
+ } else if (marker === '\\') {
393
+ current.lines.push({ kind: 'meta', text: line })
394
+ } else {
395
+ // 例如 `index abc..def 100644`、`--- a/x`、`+++ b/x` 等文件头:忽略。
396
+ }
397
+ }
398
+ return { binary, additions, deletions, hunks }
399
+ }