@caozeal/dsh-next-git 0.1.4 → 0.1.6

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/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
+ }
package/src/git.js CHANGED
@@ -290,7 +290,7 @@ export function parseStashes(raw) {
290
290
  * @param {string} raw
291
291
  * @returns {Array<{graph: string, hash: string|null, full: string|null, refs: string[], author: string, date: string, subject: string}>}
292
292
  */
293
- export function parseGraph(raw) {
293
+ export function parseGraph(raw, options = {}) {
294
294
  /** @type {Array<object>} */
295
295
  const lines = []
296
296
  for (const line of String(raw ?? '').split('\n')) {
@@ -319,6 +319,19 @@ export function parseGraph(raw) {
319
319
  subject: fields.slice(5).join('\x1f'),
320
320
  })
321
321
  }
322
+ // 逐行算 lane 色号,Client 直接用;ref 色号与线条色号来自同一次分配,
323
+ // 保证「分支徽章」和「分支线」严格同色。
324
+ const colors = assignGraphColors(lines, options.width)
325
+ const assignments = assignRefColors(lines)
326
+ for (let index = 0; index < lines.length; index += 1) {
327
+ const line = lines[index]
328
+ line.colors = colors[index]
329
+ if (line.refs && line.refs.length > 0) {
330
+ const head = line.refs.find((ref) => String(ref).indexOf('HEAD') === 0)
331
+ const color = assignments.get(canonicalRef(head || line.refs[0]))
332
+ if (color !== undefined) line.refColor = color
333
+ }
334
+ }
322
335
  return lines
323
336
  }
324
337
 
@@ -334,6 +347,309 @@ export function parseLines(raw) {
334
347
  .filter((line) => line !== '')
335
348
  }
336
349
 
350
+
351
+ /**
352
+ * 把一对「删除行 / 新增行」切成词级片段,供 diff 渲染高亮真正变化的词。
353
+ *
354
+ * 算法:先按「词 / 空白 / 标点」粗切成 token,再对两组 token 做 LCS,
355
+ * 未匹配的 token 才是变化部分。纯函数、无依赖、可单测。
356
+ *
357
+ * 注意:中文等无空格语言按字符切,否则整行会变成一个 token(等于没高亮)。
358
+ *
359
+ * @param {string} before 删除行的文本(不含前缀符号)
360
+ * @param {string} after 新增行的文本(不含前缀符号)
361
+ * @returns {{before: Array<{text: string, changed: boolean}>, after: Array<{text: string, changed: boolean}>}}
362
+ */
363
+ export function diffInline(before, after) {
364
+ const left = tokenizeInline(String(before ?? ''))
365
+ const right = tokenizeInline(String(after ?? ''))
366
+ // 任一侧为空(纯增/纯删)或任一侧过长(高亮收益低、LCS 变慢)时整体标记为变化。
367
+ if (left.length === 0 || right.length === 0 || left.length + right.length > MAX_INLINE_TOKENS) {
368
+ return {
369
+ before: [{ text: left.join(''), changed: left.length > 0 }],
370
+ after: [{ text: right.join(''), changed: right.length > 0 }],
371
+ }
372
+ }
373
+ const matched = lcsMatches(left, right)
374
+ return {
375
+ before: groupInline(left, matched.left),
376
+ after: groupInline(right, matched.right),
377
+ }
378
+ }
379
+
380
+ /** 单行词级比较的 token 上限,超过就退化成整行高亮。 */
381
+ const MAX_INLINE_TOKENS = 400
382
+
383
+ /** 是否把该 codepoint 当作可独立成词的表意字符(CJK、假名、全角标点等)。 */
384
+ function isIdeographic(code) {
385
+ return (
386
+ (code >= 0x3040 && code <= 0x30ff) || // 平假名 / 片假名
387
+ (code >= 0x3400 && code <= 0x4dbf) || // CJK 扩展 A
388
+ (code >= 0x4e00 && code <= 0x9fff) || // CJK 基本区
389
+ (code >= 0xf900 && code <= 0xfaff) || // CJK 兼容表意
390
+ (code >= 0xff00 && code <= 0xffef) || // 全角字符
391
+ (code >= 0xac00 && code <= 0xd7af) // 谚文
392
+ )
393
+ }
394
+
395
+ /** 把一行切成分隔 token:连续标识符、连续空白、单个标点/表意字符。 */
396
+ function tokenizeInline(text) {
397
+ /** @type {string[]} */
398
+ const out = []
399
+ let buffer = ''
400
+ let mode = ''
401
+ for (const char of text) {
402
+ const code = char.codePointAt(0) ?? 0
403
+ const next = /\s/.test(char) ? 'space' : isIdeographic(code) ? 'ideograph' : /[A-Za-z0-9_$]/.test(char) ? 'word' : 'punct'
404
+ if (next === 'ideograph') {
405
+ if (buffer) { out.push(buffer); buffer = '' }
406
+ out.push(char)
407
+ mode = ''
408
+ continue
409
+ }
410
+ if (next === mode) {
411
+ buffer += char
412
+ continue
413
+ }
414
+ if (buffer) out.push(buffer)
415
+ buffer = char
416
+ mode = next
417
+ }
418
+ if (buffer) out.push(buffer)
419
+ return out
420
+ }
421
+
422
+ /**
423
+ * 对左右 token 序列求最长公共子序列,返回两侧各自「被匹配上」的下标集合。
424
+ * 经典 DP;因为 MAX_INLINE_TOKENS 限制了规模,内存可以接受。
425
+ * @param {string[]} left
426
+ * @param {string[]} right
427
+ * @returns {{left: Set<number>, right: Set<number>}}
428
+ */
429
+ function lcsMatches(left, right) {
430
+ const n = left.length
431
+ const m = right.length
432
+ /** @type {Uint16Array[]} */
433
+ const table = []
434
+ for (let i = 0; i <= n; i += 1) table.push(new Uint16Array(m + 1))
435
+ for (let i = n - 1; i >= 0; i -= 1) {
436
+ for (let j = m - 1; j >= 0; j -= 1) {
437
+ table[i][j] = left[i] === right[j]
438
+ ? table[i + 1][j + 1] + 1
439
+ : Math.max(table[i + 1][j], table[i][j + 1])
440
+ }
441
+ }
442
+ const matchedLeft = new Set()
443
+ const matchedRight = new Set()
444
+ let i = 0
445
+ let j = 0
446
+ while (i < n && j < m) {
447
+ if (left[i] === right[j]) {
448
+ matchedLeft.add(i)
449
+ matchedRight.add(j)
450
+ i += 1
451
+ j += 1
452
+ } else if (table[i + 1][j] >= table[i][j + 1]) {
453
+ i += 1
454
+ } else {
455
+ j += 1
456
+ }
457
+ }
458
+ return { left: matchedLeft, right: matchedRight }
459
+ }
460
+
461
+ /**
462
+ * 把 token 按下标是否命中合并成连续片段,减少 DOM 节点数。
463
+ * @param {string[]} tokens
464
+ * @param {Set<number>} matched
465
+ * @returns {Array<{text: string, changed: boolean}>}
466
+ */
467
+ function groupInline(tokens, matched) {
468
+ /** @type {Array<{text: string, changed: boolean}>} */
469
+ const out = []
470
+ for (let index = 0; index < tokens.length; index += 1) {
471
+ const changed = !matched.has(index)
472
+ const last = out[out.length - 1]
473
+ if (last && last.changed === changed) last.text += tokens[index]
474
+ else out.push({ text: tokens[index], changed })
475
+ }
476
+ return out
477
+ }
478
+
479
+ /**
480
+ * 在一个 hunk 的行序列上就地附加词级高亮片段。
481
+ *
482
+ * 配对策略:同一 hunk 内相邻的连续删除块与连续新增块「按下标逐行配对」——
483
+ * 这是 unified diff 的常见形态,也是用户直觉上认为「这一行改成了那一行」的对应关系。
484
+ * 多余的删除行或新增行保持整行高亮。
485
+ *
486
+ * @param {Array<{kind: string, text: string}>} lines
487
+ * @returns {Array<{kind: string, text: string, old?: number, new?: number, inline?: Array<{text: string, changed: boolean}>}>}
488
+ */
489
+ export function attachInlineHunks(lines) {
490
+ const output = lines.map((line) => ({ ...line }))
491
+ let index = 0
492
+ while (index < output.length) {
493
+ if (output[index].kind !== 'del') {
494
+ index += 1
495
+ continue
496
+ }
497
+ let delEnd = index
498
+ while (delEnd < output.length && output[delEnd].kind === 'del') delEnd += 1
499
+ let addEnd = delEnd
500
+ while (addEnd < output.length && output[addEnd].kind === 'add') addEnd += 1
501
+ const delCount = delEnd - index
502
+ const addCount = addEnd - delEnd
503
+ const pairs = Math.min(delCount, addCount)
504
+ for (let offset = 0; offset < pairs; offset += 1) {
505
+ const delLine = output[index + offset]
506
+ const addLine = output[delEnd + offset]
507
+ const inline = diffInline(delLine.text, addLine.text)
508
+ // 只在真的存在可见差异时才挂片段,纯「整行替换」不额外包 span。
509
+ if (inline.before.length > 1 || inline.after.length > 1 ||
510
+ (inline.before[0] && inline.before[0].changed) || (inline.after[0] && inline.after[0].changed)) {
511
+ delLine.inline = inline.before
512
+ addLine.inline = inline.after
513
+ }
514
+ }
515
+ index = addEnd
516
+ }
517
+ return output
518
+ }
519
+
520
+ /** 提交图配色槽位数;Client 侧 CSS 提供 --gm-lane-0 .. --gm-lane-{N-1}。 */
521
+ export const GRAPH_PALETTE_SIZE = 8
522
+
523
+ /** 图表单格的连接语义:'|' 竖直、'/' 从 lane i 顶到 lane i-1 底、'\\' 从 lane i-1 顶到 lane i 底。 */
524
+ function cellLink(char, lane) {
525
+ if (char === '|') return { top: lane, bottom: lane }
526
+ if (char === '/') return { top: lane, bottom: lane - 1 }
527
+ if (char === '\\') return { top: lane - 1, bottom: lane }
528
+ if (char === '*') return { top: lane, bottom: lane }
529
+ if (char === '_') return { top: lane, bottom: lane }
530
+ return null
531
+ }
532
+
533
+ /** 取图行第 lane 个字符;越界返回空格。 */
534
+ function graphChar(text, lane) {
535
+ return lane >= 0 && lane < text.length ? text[lane] : ' '
536
+ }
537
+
538
+ /** 由分支名/远程名算出稳定色号:同名分支在任何仓库、任何一次打开都是同一种颜色。 */
539
+ export function refColorIndex(ref) {
540
+ const name = String(ref ?? '')
541
+ .replace(/^HEAD -> /, '')
542
+ .replace(/^tag: /, '')
543
+ .replace(/^origin\//, '')
544
+ let hash = 0
545
+ for (let index = 0; index < name.length; index += 1) {
546
+ hash = (hash * 31 + name.charCodeAt(index)) % 100003
547
+ }
548
+ return hash % GRAPH_PALETTE_SIZE
549
+ }
550
+
551
+ /**
552
+ * 给提交图每一行算出 lane 的颜色号。
553
+ *
554
+ * 做法是模拟一条「颜色随线段流动」的轨道:每个格子有入口 lane 和出口 lane
555
+ * (见 {@link cellLink}),入口颜色沿线段流到出口,于是同一条分支线在跨行时
556
+ * 保持同色;分支点处新出现的线段取当前未被占用的色号,避免相邻两条线撞色。
557
+ *
558
+ * 有 ref(分支/tag)的提交点会强制使用该 ref 的稳定色,这样「分支自己的颜色」
559
+ * 和「分支名徽章的颜色」一致,滚到哪都能认出来。
560
+ *
561
+ * @param {Array<{graph: string, refs?: string[]}>} lines
562
+ * @param {number} [width] 参与绘制的 lane 数;省略时按实际最长图前缀推断
563
+ * @returns {Array<Array<number>>} 每行每个 lane 的色号
564
+ */
565
+ export function assignGraphColors(lines, width) {
566
+ const rows = Array.isArray(lines) ? lines : []
567
+ let maxWidth = 0
568
+ for (const line of rows) {
569
+ const text = line && line.graph ? line.graph : ''
570
+ if (text.length > maxWidth) maxWidth = text.length
571
+ }
572
+ const cols = Number.isFinite(width) && width > 0 ? Math.min(width, maxWidth) : maxWidth
573
+ // 先给所有出现过的 ref 分配互不相同的色号(按首次出现顺序),而不是对名字取模。
574
+ // 取模在 8 色槽下约 1/8 概率撞色,两条分支同色会让整张图失去辨识度
575
+ // (实测 feat/streaming 与 fix/empty-input 就撞在同一个色号上)。
576
+ const refColors = assignRefColors(rows)
577
+ /** @type {Map<number, number>} */
578
+ let entering = new Map()
579
+ /** @type {Array<Array<number>>} */
580
+ const output = []
581
+ for (let row = 0; row < rows.length; row += 1) {
582
+ const text = rows[row] && rows[row].graph ? rows[row].graph : ''
583
+ const refs = rows[row] && Array.isArray(rows[row].refs) ? rows[row].refs : []
584
+ /** @type {Array<number>} */
585
+ const colors = new Array(cols).fill(-1)
586
+ /** @type {Map<number, number>} */
587
+ const next = new Map()
588
+ /** @type {Set<number>} */
589
+ const used = new Set()
590
+ for (let lane = 0; lane < cols; lane += 1) {
591
+ const link = cellLink(graphChar(text, lane), lane)
592
+ if (!link) continue
593
+ let color = entering.get(link.top)
594
+ if (color === undefined) {
595
+ // 新线段:挑一个这一行还没用过的色号,相邻线不会同色。
596
+ color = 0
597
+ for (let candidate = 0; candidate < GRAPH_PALETTE_SIZE; candidate += 1) {
598
+ if (!used.has(candidate)) { color = candidate; break }
599
+ }
600
+ }
601
+ used.add(color)
602
+ colors[lane] = color
603
+ if (!next.has(link.bottom)) next.set(link.bottom, color)
604
+ }
605
+ // 提交点:有 ref 时用该分支分配到的色号,并让它继续往下流。
606
+ if (refs.length > 0) {
607
+ const dot = text.indexOf('*')
608
+ if (dot >= 0 && dot < cols) {
609
+ const head = refs.find((ref) => String(ref).indexOf('HEAD') === 0)
610
+ const color = refColors.get(canonicalRef(head || refs[0]))
611
+ if (color !== undefined) {
612
+ colors[dot] = color
613
+ next.set(dot, color)
614
+ }
615
+ }
616
+ }
617
+ output.push(colors)
618
+ entering = next
619
+ }
620
+ return output
621
+ }
622
+
623
+ /**
624
+ * 给一组图行里出现过的每个 ref 分配色号(按首次出现顺序取模)。
625
+ * 顺序分配保证「同一屏里出现的分支一定不同色」,比按名字哈希取模更可靠。
626
+ * @param {Array<{refs?: string[]}>} rows
627
+ * @returns {Map<string, number>} 归一化分支名 → 色号
628
+ */
629
+ function assignRefColors(rows) {
630
+ const out = new Map()
631
+ let cursor = 0
632
+ for (const row of rows) {
633
+ const refs = row && Array.isArray(row.refs) ? row.refs : []
634
+ for (const ref of refs) {
635
+ const name = canonicalRef(ref)
636
+ if (!name || out.has(name)) continue
637
+ out.set(name, cursor % GRAPH_PALETTE_SIZE)
638
+ cursor += 1
639
+ }
640
+ }
641
+ return out
642
+ }
643
+
644
+ /** 把 `HEAD -> main`、`tag: v1`、`origin/main` 归一成用于配色的分支名。 */
645
+ function canonicalRef(ref) {
646
+ return String(ref ?? '')
647
+ .replace(/^HEAD -> /, '')
648
+ .replace(/^tag: /, '')
649
+ .replace(/^origin\//, '')
650
+ .trim()
651
+ }
652
+
337
653
  const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/
338
654
 
339
655
  /**
@@ -395,5 +711,7 @@ export function parseUnifiedDiff(raw) {
395
711
  // 例如 `index abc..def 100644`、`--- a/x`、`+++ b/x` 等文件头:忽略。
396
712
  }
397
713
  }
714
+ // 每个 hunk 的行上附加词级高亮片段(纯展示数据,UI 可忽略)。
715
+ for (const hunk of hunks) hunk.lines = attachInlineHunks(hunk.lines)
398
716
  return { binary, additions, deletions, hunks }
399
- }
717
+ }
package/src/operations.js CHANGED
@@ -27,10 +27,17 @@ const DIFF_MAX_CHARS = 400000
27
27
 
28
28
  /** 支持的操作名。 */
29
29
  const OPS = [
30
- 'snapshot', 'graph', 'diff', 'stage', 'unstage', 'switchBranch', 'deleteBranch',
30
+ 'snapshot', 'graph', 'diff', 'stage', 'unstage', 'discard', 'switchBranch', 'deleteBranch',
31
31
  'commit', 'fetch', 'pull', 'push', 'aiCommit',
32
+ 'stashPush', 'stashPop', 'stashDrop', 'setCommitPrompt',
32
33
  ]
33
34
 
35
+ /** 自定义提交提示词在 git config 里的键。用 git config 而不是新增工作区文件:
36
+ * 不污染 git status、不打扰 .gitignore,且天然区分本地/全局(写入用 --local,
37
+ * 读取走默认级联,所以用户可以先设一条全局默认再按仓库覆盖)。 */
38
+ const COMMIT_PROMPT_KEY = 'dsh.commitPrompt'
39
+ const COMMIT_PROMPT_MAX = 4000
40
+
34
41
  /** 只接受安全的 ref/远程名,避免被当作命令行选项。 */
35
42
  const SAFE_REF = /^[A-Za-z0-9._/-]+$/
36
43
 
@@ -160,10 +167,11 @@ export function createOperations(deps) {
160
167
  remotes: [],
161
168
  status: { clean: true, staged: [], unstaged: [], untracked: [], conflicted: [] },
162
169
  stashes: [],
170
+ commitPrompt: '',
163
171
  }
164
172
  }
165
173
  const root = resolved.root
166
- const [statusRes, branchesRes, remotesRes, stashRes, headRes, urlRes] = await Promise.all([
174
+ const [statusRes, branchesRes, remotesRes, stashRes, headRes, urlRes, promptRes] = await Promise.all([
167
175
  runGit(['status', '--porcelain=v1', '-z', '--branch', '--untracked-files=all'], {
168
176
  cwd: root,
169
177
  signal,
@@ -178,6 +186,7 @@ export function createOperations(deps) {
178
186
  runGit(['stash', 'list', '--format=%gd%x00%s'], { cwd: root, signal, allowFailure: true }),
179
187
  runGit(['rev-parse', '--short', 'HEAD'], { cwd: root, signal, allowFailure: true }),
180
188
  runGit(['config', '--get', 'remote.origin.url'], { cwd: root, signal, allowFailure: true }),
189
+ runGit(['config', '--get', COMMIT_PROMPT_KEY], { cwd: root, signal, allowFailure: true }),
181
190
  ])
182
191
  const parsed = parsePorcelain(statusRes.stdout)
183
192
  const head = parseBranchHeader(parsed.branchLine)
@@ -209,6 +218,7 @@ export function createOperations(deps) {
209
218
  remotes,
210
219
  status: { clean, ...groups },
211
220
  stashes: parseStashes(stashRes.stdout),
221
+ commitPrompt: promptRes.code === 0 ? promptRes.stdout.trim() : '',
212
222
  }
213
223
  }
214
224
 
@@ -311,6 +321,31 @@ export function createOperations(deps) {
311
321
  return { output: joinOutput(result.stdout, result.stderr) }
312
322
  }
313
323
 
324
+ /**
325
+ * 丢弃改动。三种模式对应变更列表里的三个分组:
326
+ * worktree —— 未暂存(git restore --worktree)
327
+ * untracked —— 未跟踪(git clean -f -d)
328
+ * all —— 已暂存,连暂存区一起回退(git restore --staged --worktree)
329
+ * 都不可恢复,前端必须二次确认后才调用。
330
+ */
331
+ async function discard(params, signal) {
332
+ const cwd = assertCwd(params.cwd)
333
+ const resolved = await repoRoot(cwd, signal)
334
+ const root = resolved.root
335
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
336
+ const mode = params.mode
337
+ if (!['worktree', 'untracked', 'all'].includes(mode)) {
338
+ throw new GitError(`未知的丢弃模式:${String(mode)}`, { code: 'invalid-params' })
339
+ }
340
+ const paths = assertPaths(params.paths)
341
+ let args
342
+ if (mode === 'untracked') args = ['clean', '-f', '-d', '--', ...paths]
343
+ else if (mode === 'all') args = ['restore', '--staged', '--worktree', '--', ...paths]
344
+ else args = ['restore', '--worktree', '--', ...paths]
345
+ const result = await runGit(args, { cwd: root, signal, timeoutMs: timeouts.write })
346
+ return { output: joinOutput(result.stdout, result.stderr) }
347
+ }
348
+
314
349
  async function switchBranch(params, signal) {
315
350
  const cwd = assertCwd(params.cwd)
316
351
  const resolved = await repoRoot(cwd, signal)
@@ -429,6 +464,80 @@ export function createOperations(deps) {
429
464
  return { branch, output: joinOutput(result.stdout, result.stderr) }
430
465
  }
431
466
 
467
+ /** stash 索引只接受非负整数,避免拼进 stash@{...} 时被注入别的 ref。 */
468
+ function assertStashIndex(value, fallback) {
469
+ const raw = value === undefined || value === null ? fallback : value
470
+ const index = Number(raw)
471
+ if (!Number.isInteger(index) || index < 0 || index > 999) {
472
+ throw new GitError(`stash 索引非法:${String(raw)}`, { code: 'invalid-params' })
473
+ }
474
+ return index
475
+ }
476
+
477
+ async function stashPush(params, signal) {
478
+ const cwd = assertCwd(params.cwd)
479
+ const resolved = await repoRoot(cwd, signal)
480
+ const root = resolved.root
481
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
482
+ const args = ['stash', 'push']
483
+ if (params.includeUntracked === true) args.push('-u')
484
+ const message = typeof params.message === 'string' ? params.message.trim().slice(0, 200) : ''
485
+ if (message !== '') args.push('-m', message)
486
+ const result = await runGit(args, { cwd: root, signal, timeoutMs: timeouts.write })
487
+ return { output: joinOutput(result.stdout, result.stderr) }
488
+ }
489
+
490
+ async function stashPop(params, signal) {
491
+ const cwd = assertCwd(params.cwd)
492
+ const resolved = await repoRoot(cwd, signal)
493
+ const root = resolved.root
494
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
495
+ const index = assertStashIndex(params.index, 0)
496
+ const result = await runGit(['stash', 'pop', `stash@{${index}}`], {
497
+ cwd: root, signal, timeoutMs: timeouts.write,
498
+ })
499
+ return { output: joinOutput(result.stdout, result.stderr) }
500
+ }
501
+
502
+ async function stashDrop(params, signal) {
503
+ const cwd = assertCwd(params.cwd)
504
+ const resolved = await repoRoot(cwd, signal)
505
+ const root = resolved.root
506
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
507
+ const index = assertStashIndex(params.index, 0)
508
+ const result = await runGit(['stash', 'drop', `stash@{${index}}`], {
509
+ cwd: root, signal, timeoutMs: timeouts.write,
510
+ })
511
+ return { output: joinOutput(result.stdout, result.stderr) }
512
+ }
513
+
514
+ /** 读写自定义提交提示词;传空串表示恢复默认。 */
515
+ async function setCommitPrompt(params, signal) {
516
+ const cwd = assertCwd(params.cwd)
517
+ const resolved = await repoRoot(cwd, signal)
518
+ const root = resolved.root
519
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
520
+ const raw = typeof params.prompt === 'string' ? params.prompt : ''
521
+ if (raw.length > COMMIT_PROMPT_MAX) {
522
+ throw new GitError(`提示词过长(上限 ${COMMIT_PROMPT_MAX} 字)`, { code: 'invalid-params' })
523
+ }
524
+ const value = raw.trim()
525
+ if (value === '') {
526
+ // 没设过时 git config --unset 退出码非 0,不算失败。
527
+ await runGit(['config', '--local', '--unset', COMMIT_PROMPT_KEY], {
528
+ cwd: root, signal, timeoutMs: timeouts.write, allowFailure: true,
529
+ })
530
+ } else {
531
+ await runGit(['config', '--local', COMMIT_PROMPT_KEY, value], {
532
+ cwd: root, signal, timeoutMs: timeouts.write,
533
+ })
534
+ }
535
+ const read = await runGit(['config', '--get', COMMIT_PROMPT_KEY], {
536
+ cwd: root, signal, allowFailure: true,
537
+ })
538
+ return { prompt: read.code === 0 ? read.stdout.trim() : '' }
539
+ }
540
+
432
541
  async function aiCommit(params, signal) {
433
542
  const cwd = assertCwd(params.cwd)
434
543
  const resolved = await repoRoot(cwd, signal)
@@ -436,6 +545,10 @@ export function createOperations(deps) {
436
545
  if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
437
546
  const hint = typeof params.hint === 'string' ? params.hint.slice(0, 500) : ''
438
547
  const context = ai() ?? {}
548
+ const promptRes = await runGit(['config', '--get', COMMIT_PROMPT_KEY], {
549
+ cwd: root, signal, allowFailure: true,
550
+ })
551
+ const systemPrompt = promptRes.code === 0 ? promptRes.stdout : ''
439
552
  return generate({
440
553
  runGit: (args, options = {}) => runGit(args, {
441
554
  cwd: root,
@@ -448,6 +561,7 @@ export function createOperations(deps) {
448
561
  signal,
449
562
  llm: context.llm,
450
563
  selection: context.selection,
564
+ systemPrompt,
451
565
  })
452
566
  }
453
567
 
@@ -457,6 +571,7 @@ export function createOperations(deps) {
457
571
  diff,
458
572
  stage,
459
573
  unstage,
574
+ discard,
460
575
  switchBranch,
461
576
  deleteBranch,
462
577
  commit,
@@ -464,6 +579,10 @@ export function createOperations(deps) {
464
579
  pull,
465
580
  push,
466
581
  aiCommit,
582
+ stashPush,
583
+ stashPop,
584
+ stashDrop,
585
+ setCommitPrompt,
467
586
  }
468
587
 
469
588
  /**
@@ -507,4 +626,4 @@ export function createOperations(deps) {
507
626
  }
508
627
 
509
628
  return { handle, ops: [...OPS], runGit }
510
- }
629
+ }