@caozeal/dsh-next-git 0.1.5 → 0.1.7
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/README.md +26 -9
- package/assets/screenshots/01-changes.png +0 -0
- package/assets/screenshots/02-commit-graph.png +0 -0
- package/assets/screenshots/03-word-level-diff.png +0 -0
- package/assets/screenshots/04-branches.png +0 -0
- package/assets/screenshots/05-graph-multibranch.png +0 -0
- package/assets/screenshots/06-fullscreen.png +0 -0
- package/assets/screenshots/README.md +24 -0
- package/lib/client.js +513 -13
- package/package.json +3 -1
- package/screenshots.json +8 -0
- package/src/ai-commit.js +65 -22
- package/src/ai-conflict.js +159 -0
- package/src/operations.js +355 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@caozeal/dsh-next-git",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "DSH Git 管理插件:分支切换、变更暂存、提交/推送/拉取、AI 提交信息、提交图与 diff 查看",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
"src",
|
|
17
17
|
"lib/client.js",
|
|
18
18
|
"cordis.patch.yml",
|
|
19
|
+
"screenshots.json",
|
|
20
|
+
"assets/screenshots",
|
|
19
21
|
"locale/*.json",
|
|
20
22
|
"icon.svg",
|
|
21
23
|
"icon.png"
|
package/screenshots.json
ADDED
package/src/ai-commit.js
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
* git-manager — AI 提交信息生成。
|
|
3
3
|
*
|
|
4
4
|
* 流程:收集「已暂存 diff(为空则工作区 diff)→ 最近提交风格 → 调用默认模型的
|
|
5
|
-
* llm.stream」→
|
|
6
|
-
*
|
|
5
|
+
* llm.stream」→ 清理成「标题 + 可选正文」。任何一步不可用都退回确定性文案
|
|
6
|
+
* (fallback),让面板始终能拿到一条可编辑的建议。
|
|
7
|
+
*
|
|
8
|
+
* 系统提示词可由仓库自定义(git config dsh.commitPrompt),只替换「怎么写」的规则,
|
|
9
|
+
* 不改变「给模型看什么数据」——diff/文件/最近提交这条装配逻辑始终由本模块掌握,
|
|
10
|
+
* 避免用户把模板写坏后模型看不到 diff 而胡乱编造。
|
|
7
11
|
*/
|
|
8
12
|
import { GitError } from './git.js'
|
|
9
13
|
|
|
@@ -11,11 +15,13 @@ const MAX_DIFF_CHARS = 12000
|
|
|
11
15
|
/** 单次生成的 token 预算:reasoning 模型会先消耗思考 token,200 太小容易截断。 */
|
|
12
16
|
const MAX_OUTPUT_TOKENS = 600
|
|
13
17
|
|
|
14
|
-
const
|
|
18
|
+
export const DEFAULT_SYSTEM_PROMPT = [
|
|
15
19
|
'你是一个 Git 提交信息生成器。',
|
|
16
|
-
'
|
|
17
|
-
'
|
|
18
|
-
'
|
|
20
|
+
'第一行是标题:Conventional Commits 风格(feat:/fix:/refactor:/docs:/chore: 等),不超过 72 个字符,不以句号结尾。',
|
|
21
|
+
'如果这次改动需要说明动机、影响面或取舍,空一行后写正文,正文每行不超过 72 个字符,可以分段。',
|
|
22
|
+
'改动很小、一句话能说清时不要硬凑正文。',
|
|
23
|
+
'不要解释、不要代码块、不要引号。',
|
|
24
|
+
'跟随仓库最近提交的语言:最近提交多为中文就用中文,否则用英文。',
|
|
19
25
|
].join('')
|
|
20
26
|
|
|
21
27
|
/** 从 diff 与最近提交构造 user prompt。 */
|
|
@@ -48,6 +54,44 @@ export function sanitizeSubject(text) {
|
|
|
48
54
|
return subject
|
|
49
55
|
}
|
|
50
56
|
|
|
57
|
+
/** 去掉整行的代码块围栏,保留内容。 */
|
|
58
|
+
function stripFences(text) {
|
|
59
|
+
return String(text ?? '').replace(/^[ \t]*```[a-zA-Z0-9_-]*[ \t]*$/gm, '')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 清理模型输出的正文部分:去围栏、去首尾空行、去「正文:」这类前缀、限长。
|
|
64
|
+
* 不做逐行 trim——正文里的缩进(列表、代码片段)要保留。
|
|
65
|
+
* @param {string} text
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
export function sanitizeBody(text) {
|
|
69
|
+
let value = stripFences(text).replace(/\r\n/g, '\n')
|
|
70
|
+
const lines = value.split('\n').map((line) => line.replace(/[ \t]+$/, ''))
|
|
71
|
+
while (lines.length > 0 && lines[0].trim() === '') lines.shift()
|
|
72
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
|
|
73
|
+
let body = lines.join('\n').trim()
|
|
74
|
+
body = body.replace(/^(commit body|提交正文|正文)\s*[::]\s*/i, '')
|
|
75
|
+
if (body.length > 2000) body = body.slice(0, 2000).trim()
|
|
76
|
+
return body
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 把模型输出拆成「标题 + 正文」。
|
|
81
|
+
* 模型偶尔只给标题,也可能给「标题 / 空行 / 正文」,两种都要能吃下。
|
|
82
|
+
* @param {string} text
|
|
83
|
+
* @returns {{subject: string, body: string}}
|
|
84
|
+
*/
|
|
85
|
+
export function parseCommitMessage(text) {
|
|
86
|
+
const cleaned = stripFences(text).replace(/\r\n/g, '\n')
|
|
87
|
+
const lines = cleaned.split('\n')
|
|
88
|
+
let index = 0
|
|
89
|
+
while (index < lines.length && lines[index].trim() === '') index += 1
|
|
90
|
+
const subjectLine = index < lines.length ? lines[index] : ''
|
|
91
|
+
const rest = lines.slice(index + 1).join('\n')
|
|
92
|
+
return { subject: sanitizeSubject(subjectLine), body: sanitizeBody(rest) }
|
|
93
|
+
}
|
|
94
|
+
|
|
51
95
|
/** 最近提交是否以中文为主。 */
|
|
52
96
|
function looksChinese(subjects) {
|
|
53
97
|
const joined = subjects.join('')
|
|
@@ -104,8 +148,9 @@ export function fallbackSubject({ files, stagedCount, Chinese }) {
|
|
|
104
148
|
* signal?: AbortSignal,
|
|
105
149
|
* llm?: unknown,
|
|
106
150
|
* selection?: {provider?: string, model?: string, reasoningEffort?: string},
|
|
151
|
+
* systemPrompt?: string,
|
|
107
152
|
* }} options
|
|
108
|
-
* @returns {Promise<{message: string, model: string|null, fallback: boolean}>}
|
|
153
|
+
* @returns {Promise<{message: string, subject: string, body: string, model: string|null, fallback: boolean}>}
|
|
109
154
|
*/
|
|
110
155
|
export async function generateCommitMessage(options) {
|
|
111
156
|
const {
|
|
@@ -117,6 +162,7 @@ export async function generateCommitMessage(options) {
|
|
|
117
162
|
selection,
|
|
118
163
|
files = [],
|
|
119
164
|
stagedCount = 0,
|
|
165
|
+
systemPrompt = '',
|
|
120
166
|
} = options
|
|
121
167
|
|
|
122
168
|
const staged = await runGit(['diff', '--cached', '--no-color'], { cwd, signal, allowFailure: true })
|
|
@@ -144,13 +190,12 @@ export async function generateCommitMessage(options) {
|
|
|
144
190
|
|
|
145
191
|
const canUseLlm = Boolean(llm && selection && selection.provider && selection.model && diff.trim())
|
|
146
192
|
if (!canUseLlm) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
model,
|
|
150
|
-
fallback: true,
|
|
151
|
-
}
|
|
193
|
+
const subject = fallbackSubject({ files: changedFiles, stagedCount, Chinese: chinese })
|
|
194
|
+
return { message: subject, subject, body: '', model, fallback: true }
|
|
152
195
|
}
|
|
153
196
|
|
|
197
|
+
// 用户自定义提示词:只覆盖「怎么写」的规则,装配数据仍由 buildPrompt 负责。
|
|
198
|
+
const customPrompt = typeof systemPrompt === 'string' ? systemPrompt.trim().slice(0, 4000) : ''
|
|
154
199
|
const prompt = buildPrompt({ diff, files: changedFiles, recent, hint })
|
|
155
200
|
let text = ''
|
|
156
201
|
try {
|
|
@@ -158,7 +203,7 @@ export async function generateCommitMessage(options) {
|
|
|
158
203
|
provider: selection.provider,
|
|
159
204
|
model: selection.model,
|
|
160
205
|
reasoningEffort: selection.reasoningEffort,
|
|
161
|
-
system:
|
|
206
|
+
system: customPrompt === '' ? DEFAULT_SYSTEM_PROMPT : customPrompt,
|
|
162
207
|
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
|
163
208
|
maxTokens: MAX_OUTPUT_TOKENS,
|
|
164
209
|
temperature: 0.2,
|
|
@@ -182,13 +227,11 @@ export async function generateCommitMessage(options) {
|
|
|
182
227
|
throw new GitError(`模型生成失败:${message}`, { code: 'ai-unavailable' })
|
|
183
228
|
}
|
|
184
229
|
|
|
185
|
-
const
|
|
186
|
-
if (!looksLikeSubject(subject)) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
model,
|
|
190
|
-
fallback: true,
|
|
191
|
-
}
|
|
230
|
+
const parsed = parseCommitMessage(text)
|
|
231
|
+
if (!looksLikeSubject(parsed.subject)) {
|
|
232
|
+
const subject = fallbackSubject({ files: changedFiles, stagedCount, Chinese: chinese })
|
|
233
|
+
return { message: subject, subject, body: '', model, fallback: true }
|
|
192
234
|
}
|
|
193
|
-
|
|
194
|
-
}
|
|
235
|
+
const message = parsed.body === '' ? parsed.subject : parsed.subject + '\n\n' + parsed.body
|
|
236
|
+
return { message, subject: parsed.subject, body: parsed.body, model, fallback: false }
|
|
237
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-manager — AI 冲突解决。
|
|
3
|
+
*
|
|
4
|
+
* 冲突文件有三份 stage:共同祖先(:1:)、当前分支(:2:)、合入分支(:3:)。
|
|
5
|
+
* 这里把它们装配成 prompt,让模型输出「解决后的完整文件」。
|
|
6
|
+
*
|
|
7
|
+
* 本模块只做清理与校验,**不写盘**:真正的写入由 operations 的 applyConflict 完成,
|
|
8
|
+
* 而且写之前会再校验一次是否残留冲突标记。这样即使模型胡说,也不会把
|
|
9
|
+
* `<<<<<<<` 直接 stage 进仓库。
|
|
10
|
+
*/
|
|
11
|
+
import { GitError } from './git.js'
|
|
12
|
+
|
|
13
|
+
/** 单份版本最多送给模型多少字符;再大就不是「看懂了再合」,而是碰运气。 */
|
|
14
|
+
export const MAX_SIDE_CHARS = 40000
|
|
15
|
+
const MAX_OUTPUT_TOKENS = 1600
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_SYSTEM_PROMPT = [
|
|
18
|
+
'你是一个 Git 合并冲突解决器。',
|
|
19
|
+
'用户会给出冲突文件的三份内容:base(共同祖先)、ours(当前分支)、theirs(合入分支)。',
|
|
20
|
+
'请输出解决冲突后的完整文件内容,遵守:',
|
|
21
|
+
'- 保留双方各自有价值的改动;同一处语义等价的重复只保留一份。',
|
|
22
|
+
'- 版本号、依赖版本这类冲突取更新/更高的一方,并与文件其余部分保持一致。',
|
|
23
|
+
'- 只输出文件内容本身:不要解释、不要 Markdown 围栏、不要行号、不要「以下是」这类开场白。',
|
|
24
|
+
'- 输出必须能直接编译或解析:括号、缩进、import 与文件其余部分自洽。',
|
|
25
|
+
'- 拿不准时选择让整个文件保持自洽的一侧,不要把两边拼贴成互相矛盾的代码。',
|
|
26
|
+
'- 不要臆造两边都没有的新逻辑。',
|
|
27
|
+
].join('\n')
|
|
28
|
+
|
|
29
|
+
/** 冲突标记只认行首的 7 个 < 或 >(`=======` 可能合法出现在 Markdown 里,不查)。 */
|
|
30
|
+
export function hasConflictMarkers(text) {
|
|
31
|
+
return String(text ?? '')
|
|
32
|
+
.split('\n')
|
|
33
|
+
.some((line) => /^(<{7}|>{7})(\s|$)/.test(line))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 取最长的围栏代码块;模型常常在代码块前后加一句解释。 */
|
|
37
|
+
export function extractFenced(text) {
|
|
38
|
+
const source = String(text ?? '')
|
|
39
|
+
const re = /^[ \t]*```[^\n]*\n([\s\S]*?)^[ \t]*```[ \t]*$/gm
|
|
40
|
+
let match
|
|
41
|
+
let best = null
|
|
42
|
+
while ((match = re.exec(source)) !== null) {
|
|
43
|
+
const block = match[1]
|
|
44
|
+
if (best === null || block.length > best.length) best = block
|
|
45
|
+
}
|
|
46
|
+
return best
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 清理模型输出为文件内容。
|
|
51
|
+
* 只剥围栏和首尾空行,绝不逐行 trim —— 缩进就是语义。
|
|
52
|
+
* @param {string} text
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function sanitizeResolution(text) {
|
|
56
|
+
const fenced = extractFenced(text)
|
|
57
|
+
let value = fenced !== null ? fenced : String(text ?? '')
|
|
58
|
+
value = value.replace(/\r\n/g, '\n')
|
|
59
|
+
if (fenced === null) {
|
|
60
|
+
// 没有围栏时,模型可能仍写了整行 ``` 围栏
|
|
61
|
+
value = value.replace(/^[ \t]*```[a-zA-Z0-9_-]*[ \t]*$/gm, '')
|
|
62
|
+
}
|
|
63
|
+
return value.replace(/\n{0,2}$/, '\n')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 校验模型给出的解决结果。不合格就抛错,**不要**用一个坏结果去覆盖用户文件。
|
|
68
|
+
* @param {string} suggested
|
|
69
|
+
*/
|
|
70
|
+
export function validateResolution(suggested) {
|
|
71
|
+
if (typeof suggested !== 'string') {
|
|
72
|
+
throw new GitError('模型没有返回内容', { code: 'ai-unavailable' })
|
|
73
|
+
}
|
|
74
|
+
if (suggested.trim() === '') {
|
|
75
|
+
throw new GitError('模型返回了空文件,未采用', { code: 'ai-unavailable' })
|
|
76
|
+
}
|
|
77
|
+
if (hasConflictMarkers(suggested)) {
|
|
78
|
+
throw new GitError('模型输出仍包含冲突标记,未采用', { code: 'ai-unavailable' })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function capSide(label, text) {
|
|
83
|
+
const value = String(text ?? '')
|
|
84
|
+
if (value.length <= MAX_SIDE_CHARS) return value
|
|
85
|
+
return value.slice(0, MAX_SIDE_CHARS) + `\n…(${label} 超过 ${MAX_SIDE_CHARS} 字符,已截断)`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 构造 user prompt。 */
|
|
89
|
+
export function buildConflictPrompt({ path, base, ours, theirs, branch, incoming, hint }) {
|
|
90
|
+
const parts = ['文件:' + path]
|
|
91
|
+
if (branch) parts.push('当前分支(ours):' + branch)
|
|
92
|
+
if (incoming) parts.push('合入分支(theirs):' + incoming)
|
|
93
|
+
if (hint) parts.push('用户补充说明:' + hint)
|
|
94
|
+
parts.push('')
|
|
95
|
+
if (base !== null && base !== undefined) {
|
|
96
|
+
parts.push('=== base(共同祖先)===', capSide('base', base), '')
|
|
97
|
+
} else {
|
|
98
|
+
parts.push('=== base ===', '(这个文件没有共同祖先版本,是 add/add 冲突)', '')
|
|
99
|
+
}
|
|
100
|
+
parts.push('=== ours(当前分支)===', capSide('ours', ours), '')
|
|
101
|
+
parts.push('=== theirs(合入分支)===', capSide('theirs', theirs))
|
|
102
|
+
return parts.join('\n')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 让模型给出一份解决结果。
|
|
107
|
+
* @param {{
|
|
108
|
+
* path: string, base: string|null, ours: string, theirs: string,
|
|
109
|
+
* branch?: string|null, incoming?: string|null, hint?: string,
|
|
110
|
+
* llm?: unknown, selection?: {provider?: string, model?: string, reasoningEffort?: string},
|
|
111
|
+
* signal?: AbortSignal, systemPrompt?: string,
|
|
112
|
+
* }} options
|
|
113
|
+
* @returns {Promise<{suggested: string, model: string}>}
|
|
114
|
+
*/
|
|
115
|
+
export async function resolveConflict(options) {
|
|
116
|
+
const { llm, selection, signal } = options
|
|
117
|
+
if (!llm || !selection || !selection.provider || !selection.model) {
|
|
118
|
+
throw new GitError('没有可用的默认模型,无法生成解决建议', { code: 'ai-unavailable' })
|
|
119
|
+
}
|
|
120
|
+
const model = selection.provider + '/' + selection.model
|
|
121
|
+
const systemPrompt = typeof options.systemPrompt === 'string' && options.systemPrompt.trim() !== ''
|
|
122
|
+
? options.systemPrompt.trim().slice(0, 4000)
|
|
123
|
+
: DEFAULT_SYSTEM_PROMPT
|
|
124
|
+
const prompt = buildConflictPrompt(options)
|
|
125
|
+
|
|
126
|
+
let text = ''
|
|
127
|
+
try {
|
|
128
|
+
const stream = llm.stream({
|
|
129
|
+
provider: selection.provider,
|
|
130
|
+
model: selection.model,
|
|
131
|
+
reasoningEffort: selection.reasoningEffort,
|
|
132
|
+
system: systemPrompt,
|
|
133
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
|
134
|
+
maxTokens: MAX_OUTPUT_TOKENS,
|
|
135
|
+
temperature: 0.1,
|
|
136
|
+
signal,
|
|
137
|
+
})
|
|
138
|
+
for await (const chunk of stream) {
|
|
139
|
+
if (chunk.type === 'text-delta') text += chunk.text
|
|
140
|
+
if (chunk.type === 'finish') {
|
|
141
|
+
const kind = chunk.reason && chunk.reason.kind
|
|
142
|
+
if (kind === 'error' || kind === 'aborted') {
|
|
143
|
+
const failure = chunk.reason.failure
|
|
144
|
+
throw new GitError('模型生成失败:' + (failure && failure.message ? failure.message : kind), {
|
|
145
|
+
code: 'ai-unavailable',
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error instanceof GitError) throw error
|
|
152
|
+
const message = error && error.message ? error.message : String(error)
|
|
153
|
+
throw new GitError('模型生成失败:' + message, { code: 'ai-unavailable' })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const suggested = sanitizeResolution(text)
|
|
157
|
+
validateResolution(suggested)
|
|
158
|
+
return { suggested, model }
|
|
159
|
+
}
|