@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caozeal/dsh-next-git",
3
- "version": "0.1.5",
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"
@@ -0,0 +1,8 @@
1
+ [
2
+ "assets/screenshots/01-changes.png",
3
+ "assets/screenshots/02-commit-graph.png",
4
+ "assets/screenshots/03-word-level-diff.png",
5
+ "assets/screenshots/04-branches.png",
6
+ "assets/screenshots/05-graph-multibranch.png",
7
+ "assets/screenshots/06-fullscreen.png"
8
+ ]
package/src/ai-commit.js CHANGED
@@ -2,8 +2,12 @@
2
2
  * git-manager — AI 提交信息生成。
3
3
  *
4
4
  * 流程:收集「已暂存 diff(为空则工作区 diff)→ 最近提交风格 → 调用默认模型的
5
- * llm.stream」→ 清理成一行提交信息。任何一步不可用都退回确定性文案(fallback),
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 SYSTEM_PROMPT = [
18
+ export const DEFAULT_SYSTEM_PROMPT = [
15
19
  '你是一个 Git 提交信息生成器。',
16
- '只输出一行提交信息,不要解释、不要代码块、不要引号、不要句号结尾。',
17
- '使用 Conventional Commits 风格(如 feat:/fix:/refactor:/docs:/chore:),',
18
- '不超过 72 个字符。跟随仓库最近提交的语言:最近提交多为中文就用中文,否则用英文。',
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
- return {
148
- message: fallbackSubject({ files: changedFiles, stagedCount, Chinese: chinese }),
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: SYSTEM_PROMPT,
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 subject = sanitizeSubject(text)
186
- if (!looksLikeSubject(subject)) {
187
- return {
188
- message: fallbackSubject({ files: changedFiles, stagedCount, Chinese: chinese }),
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
- return { message: subject, model, fallback: false }
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
+ }