@caozeal/dsh-next-git 0.0.0-stage → 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 +169 -2
- package/cordis.patch.yml +3 -0
- package/icon.png +0 -0
- package/icon.svg +1 -0
- package/index.js +171 -0
- package/lib/client.js +1790 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +68 -4
- package/src/ai-commit.js +194 -0
- package/src/git.js +399 -0
- package/src/operations.js +510 -0
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@caozeal/dsh-next-git",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.3",
|
|
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
|
+
}
|
package/src/ai-commit.js
ADDED
|
@@ -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
|
+
}
|