@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/src/operations.js CHANGED
@@ -2,9 +2,12 @@
2
2
  * git-manager — RPC 操作实现。
3
3
  *
4
4
  * 每个操作都是一次独立的请求:解析 cwd → 找到仓库根 → 读写 git → 返回纯 JSON。
5
- * `exec` 由 Host 入口注入(真实子进程或单测的假实现),因此本文件没有 I/O 依赖。
5
+ * `exec` 由 Host 入口注入(真实子进程或单测的假实现),因此本文件没有 git 之外的 I/O;
6
+ * 唯一的例外是 applyConflict 要把解决结果写回工作区,那一步用 `writeFile`
7
+ *(可通过 deps.fileIO 注入假实现,单测不必碰磁盘)。
6
8
  */
7
- import { isAbsolute } from 'node:path'
9
+ import { isAbsolute, join } from 'node:path'
10
+ import { writeFile } from 'node:fs/promises'
8
11
  import {
9
12
  GitError,
10
13
  classifyEntries,
@@ -18,6 +21,7 @@ import {
18
21
  parseUnifiedDiff,
19
22
  } from './git.js'
20
23
  import { generateCommitMessage } from './ai-commit.js'
24
+ import { hasConflictMarkers, resolveConflict } from './ai-conflict.js'
21
25
 
22
26
  const READ_TIMEOUT = 30000
23
27
  const WRITE_TIMEOUT = 30000
@@ -27,10 +31,18 @@ const DIFF_MAX_CHARS = 400000
27
31
 
28
32
  /** 支持的操作名。 */
29
33
  const OPS = [
30
- 'snapshot', 'graph', 'diff', 'stage', 'unstage', 'switchBranch', 'deleteBranch',
34
+ 'snapshot', 'graph', 'diff', 'stage', 'unstage', 'discard', 'switchBranch', 'deleteBranch',
31
35
  'commit', 'fetch', 'pull', 'push', 'aiCommit',
36
+ 'stashPush', 'stashPop', 'stashDrop', 'setCommitPrompt',
37
+ 'conflictInfo', 'aiConflict', 'applyConflict', 'takeSide',
32
38
  ]
33
39
 
40
+ /** 自定义提交提示词在 git config 里的键。用 git config 而不是新增工作区文件:
41
+ * 不污染 git status、不打扰 .gitignore,且天然区分本地/全局(写入用 --local,
42
+ * 读取走默认级联,所以用户可以先设一条全局默认再按仓库覆盖)。 */
43
+ const COMMIT_PROMPT_KEY = 'dsh.commitPrompt'
44
+ const COMMIT_PROMPT_MAX = 4000
45
+
34
46
  /** 只接受安全的 ref/远程名,避免被当作命令行选项。 */
35
47
  const SAFE_REF = /^[A-Za-z0-9._/-]+$/
36
48
 
@@ -60,6 +72,28 @@ function assertPaths(paths) {
60
72
  })
61
73
  }
62
74
 
75
+ /**
76
+ * 只接受仓库内的相对路径。
77
+ * applyConflict 会把内容写到 `join(root, path)`,这里必须挡住
78
+ * 绝对路径与 `..`,否则一个构造过的 path 就能写到仓库外。
79
+ */
80
+ function assertRelPath(value) {
81
+ if (typeof value !== 'string' || value.trim() === '' || value.includes('\0')) {
82
+ throw new GitError('path 非法', { code: 'invalid-params' })
83
+ }
84
+ if (isAbsolute(value)) {
85
+ throw new GitError(`path 必须是仓库内相对路径:${value}`, { code: 'invalid-params' })
86
+ }
87
+ const segments = value.split('/').filter((segment) => segment !== '' && segment !== '.')
88
+ if (segments.includes('..')) {
89
+ throw new GitError(`path 不能包含 ..:${value}`, { code: 'invalid-params' })
90
+ }
91
+ if (segments.length === 0) {
92
+ throw new GitError('path 非法', { code: 'invalid-params' })
93
+ }
94
+ return segments.join('/')
95
+ }
96
+
63
97
  function assertRef(value, label) {
64
98
  if (typeof value !== 'string' || value.trim() === '') {
65
99
  throw new GitError(`缺少 ${label} 参数`, { code: 'invalid-params' })
@@ -92,6 +126,7 @@ export function createOperations(deps) {
92
126
  const { exec } = deps
93
127
  const ai = deps.ai ?? (() => null)
94
128
  const generate = deps.generateCommitMessage ?? generateCommitMessage
129
+ const writeFileImpl = (deps.fileIO && deps.fileIO.writeFile) ?? writeFile
95
130
  const timeouts = {
96
131
  read: READ_TIMEOUT,
97
132
  write: WRITE_TIMEOUT,
@@ -160,10 +195,11 @@ export function createOperations(deps) {
160
195
  remotes: [],
161
196
  status: { clean: true, staged: [], unstaged: [], untracked: [], conflicted: [] },
162
197
  stashes: [],
198
+ commitPrompt: '',
163
199
  }
164
200
  }
165
201
  const root = resolved.root
166
- const [statusRes, branchesRes, remotesRes, stashRes, headRes, urlRes] = await Promise.all([
202
+ const [statusRes, branchesRes, remotesRes, stashRes, headRes, urlRes, promptRes] = await Promise.all([
167
203
  runGit(['status', '--porcelain=v1', '-z', '--branch', '--untracked-files=all'], {
168
204
  cwd: root,
169
205
  signal,
@@ -178,6 +214,7 @@ export function createOperations(deps) {
178
214
  runGit(['stash', 'list', '--format=%gd%x00%s'], { cwd: root, signal, allowFailure: true }),
179
215
  runGit(['rev-parse', '--short', 'HEAD'], { cwd: root, signal, allowFailure: true }),
180
216
  runGit(['config', '--get', 'remote.origin.url'], { cwd: root, signal, allowFailure: true }),
217
+ runGit(['config', '--get', COMMIT_PROMPT_KEY], { cwd: root, signal, allowFailure: true }),
181
218
  ])
182
219
  const parsed = parsePorcelain(statusRes.stdout)
183
220
  const head = parseBranchHeader(parsed.branchLine)
@@ -209,6 +246,7 @@ export function createOperations(deps) {
209
246
  remotes,
210
247
  status: { clean, ...groups },
211
248
  stashes: parseStashes(stashRes.stdout),
249
+ commitPrompt: promptRes.code === 0 ? promptRes.stdout.trim() : '',
212
250
  }
213
251
  }
214
252
 
@@ -311,6 +349,31 @@ export function createOperations(deps) {
311
349
  return { output: joinOutput(result.stdout, result.stderr) }
312
350
  }
313
351
 
352
+ /**
353
+ * 丢弃改动。三种模式对应变更列表里的三个分组:
354
+ * worktree —— 未暂存(git restore --worktree)
355
+ * untracked —— 未跟踪(git clean -f -d)
356
+ * all —— 已暂存,连暂存区一起回退(git restore --staged --worktree)
357
+ * 都不可恢复,前端必须二次确认后才调用。
358
+ */
359
+ async function discard(params, signal) {
360
+ const cwd = assertCwd(params.cwd)
361
+ const resolved = await repoRoot(cwd, signal)
362
+ const root = resolved.root
363
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
364
+ const mode = params.mode
365
+ if (!['worktree', 'untracked', 'all'].includes(mode)) {
366
+ throw new GitError(`未知的丢弃模式:${String(mode)}`, { code: 'invalid-params' })
367
+ }
368
+ const paths = assertPaths(params.paths)
369
+ let args
370
+ if (mode === 'untracked') args = ['clean', '-f', '-d', '--', ...paths]
371
+ else if (mode === 'all') args = ['restore', '--staged', '--worktree', '--', ...paths]
372
+ else args = ['restore', '--worktree', '--', ...paths]
373
+ const result = await runGit(args, { cwd: root, signal, timeoutMs: timeouts.write })
374
+ return { output: joinOutput(result.stdout, result.stderr) }
375
+ }
376
+
314
377
  async function switchBranch(params, signal) {
315
378
  const cwd = assertCwd(params.cwd)
316
379
  const resolved = await repoRoot(cwd, signal)
@@ -429,6 +492,278 @@ export function createOperations(deps) {
429
492
  return { branch, output: joinOutput(result.stdout, result.stderr) }
430
493
  }
431
494
 
495
+ /** stash 索引只接受非负整数,避免拼进 stash@{...} 时被注入别的 ref。 */
496
+ function assertStashIndex(value, fallback) {
497
+ const raw = value === undefined || value === null ? fallback : value
498
+ const index = Number(raw)
499
+ if (!Number.isInteger(index) || index < 0 || index > 999) {
500
+ throw new GitError(`stash 索引非法:${String(raw)}`, { code: 'invalid-params' })
501
+ }
502
+ return index
503
+ }
504
+
505
+ async function stashPush(params, signal) {
506
+ const cwd = assertCwd(params.cwd)
507
+ const resolved = await repoRoot(cwd, signal)
508
+ const root = resolved.root
509
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
510
+ const args = ['stash', 'push']
511
+ if (params.includeUntracked === true) args.push('-u')
512
+ const message = typeof params.message === 'string' ? params.message.trim().slice(0, 200) : ''
513
+ if (message !== '') args.push('-m', message)
514
+ const result = await runGit(args, { cwd: root, signal, timeoutMs: timeouts.write })
515
+ return { output: joinOutput(result.stdout, result.stderr) }
516
+ }
517
+
518
+ async function stashPop(params, signal) {
519
+ const cwd = assertCwd(params.cwd)
520
+ const resolved = await repoRoot(cwd, signal)
521
+ const root = resolved.root
522
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
523
+ const index = assertStashIndex(params.index, 0)
524
+ const result = await runGit(['stash', 'pop', `stash@{${index}}`], {
525
+ cwd: root, signal, timeoutMs: timeouts.write,
526
+ })
527
+ return { output: joinOutput(result.stdout, result.stderr) }
528
+ }
529
+
530
+ async function stashDrop(params, signal) {
531
+ const cwd = assertCwd(params.cwd)
532
+ const resolved = await repoRoot(cwd, signal)
533
+ const root = resolved.root
534
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
535
+ const index = assertStashIndex(params.index, 0)
536
+ const result = await runGit(['stash', 'drop', `stash@{${index}}`], {
537
+ cwd: root, signal, timeoutMs: timeouts.write,
538
+ })
539
+ return { output: joinOutput(result.stdout, result.stderr) }
540
+ }
541
+
542
+ /** 读写自定义提交提示词;传空串表示恢复默认。 */
543
+ async function setCommitPrompt(params, signal) {
544
+ const cwd = assertCwd(params.cwd)
545
+ const resolved = await repoRoot(cwd, signal)
546
+ const root = resolved.root
547
+ if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
548
+ const raw = typeof params.prompt === 'string' ? params.prompt : ''
549
+ if (raw.length > COMMIT_PROMPT_MAX) {
550
+ throw new GitError(`提示词过长(上限 ${COMMIT_PROMPT_MAX} 字)`, { code: 'invalid-params' })
551
+ }
552
+ const value = raw.trim()
553
+ if (value === '') {
554
+ // 没设过时 git config --unset 退出码非 0,不算失败。
555
+ await runGit(['config', '--local', '--unset', COMMIT_PROMPT_KEY], {
556
+ cwd: root, signal, timeoutMs: timeouts.write, allowFailure: true,
557
+ })
558
+ } else {
559
+ await runGit(['config', '--local', COMMIT_PROMPT_KEY, value], {
560
+ cwd: root, signal, timeoutMs: timeouts.write,
561
+ })
562
+ }
563
+ const read = await runGit(['config', '--get', COMMIT_PROMPT_KEY], {
564
+ cwd: root, signal, allowFailure: true,
565
+ })
566
+ return { prompt: read.code === 0 ? read.stdout.trim() : '' }
567
+ }
568
+
569
+ // ---------------------------------------------------------------- 冲突解决
570
+
571
+ /** 列出某个冲突文件在索引里的三份 stage(1=base 2=ours 3=theirs)。 */
572
+ async function conflictStages(root, path, signal) {
573
+ const res = await runGit(['ls-files', '-u', '-z', '--', path], {
574
+ cwd: root, signal, allowFailure: true,
575
+ })
576
+ const stages = {}
577
+ for (const chunk of String(res.stdout || '').split('\0')) {
578
+ if (chunk === '') continue
579
+ const tab = chunk.indexOf('\t')
580
+ if (tab < 0) continue
581
+ // 格式:<mode> <hash> <stage>\t<path>
582
+ const meta = chunk.slice(0, tab).split(' ')
583
+ const hash = meta[1]
584
+ const stage = Number(meta[2])
585
+ if (!hash || !Number.isInteger(stage) || stage < 1 || stage > 3) continue
586
+ stages[stage] = hash
587
+ }
588
+ return stages
589
+ }
590
+
591
+ async function readBlob(root, hash, signal) {
592
+ if (!hash) return null
593
+ const res = await runGit(['cat-file', '-p', hash], { cwd: root, signal, allowFailure: true })
594
+ return res.code === 0 ? res.stdout : null
595
+ }
596
+
597
+ /** 还剩几个未解决冲突(applyConflict / takeSide 之后用它回执)。 */
598
+ async function unmergedCount(root, signal) {
599
+ const res = await runGit(['diff', '--name-only', '--diff-filter=U', '-z'], {
600
+ cwd: root, signal, allowFailure: true,
601
+ })
602
+ return String(res.stdout || '').split('\0').filter(Boolean).length
603
+ }
604
+
605
+ /** 合入侧的 ref 名:merge 看 MERGE_HEAD,rebase 看 REBASE_HEAD。 */
606
+ async function incomingRef(root, signal) {
607
+ for (const ref of ['MERGE_HEAD', 'REBASE_HEAD']) {
608
+ const exists = await runGit(['rev-parse', '--verify', '-q', ref], {
609
+ cwd: root, signal, allowFailure: true,
610
+ })
611
+ if (exists.code !== 0) continue
612
+ const name = await runGit(['name-rev', '--name-only', ref], {
613
+ cwd: root, signal, allowFailure: true,
614
+ })
615
+ if (name.code === 0 && name.stdout.trim() !== '') return name.stdout.trim()
616
+ const subject = await runGit(['log', '-1', '--pretty=%s', ref], {
617
+ cwd: root, signal, allowFailure: true,
618
+ })
619
+ if (subject.code === 0 && subject.stdout.trim() !== '') return subject.stdout.trim()
620
+ }
621
+ return null
622
+ }
623
+
624
+ /** 读一个冲突文件的三份版本;不是冲突文件就报 invalid-params。 */
625
+ async function loadConflict(root, path, signal) {
626
+ const stages = await conflictStages(root, path, signal)
627
+ if (Object.keys(stages).length === 0) {
628
+ throw new GitError(`该文件没有未解决的冲突:${path}`, { code: 'invalid-params' })
629
+ }
630
+ const [base, ours, theirs] = await Promise.all([
631
+ readBlob(root, stages[1], signal),
632
+ readBlob(root, stages[2], signal),
633
+ readBlob(root, stages[3], signal),
634
+ ])
635
+ return { stages, base, ours: ours ?? '', theirs: theirs ?? '' }
636
+ }
637
+
638
+ async function conflictInfo(params, signal) {
639
+ const cwd = assertCwd(params.cwd)
640
+ const resolved = await repoRoot(cwd, signal)
641
+ const root = resolved.root
642
+ if (!root) {
643
+ throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
644
+ }
645
+ const path = assertRelPath(params.path)
646
+ const info = await loadConflict(root, path, signal)
647
+ const branch = await currentBranch(root, signal)
648
+ const incoming = await incomingRef(root, signal)
649
+ return {
650
+ path,
651
+ branch,
652
+ incoming,
653
+ hasBase: info.base !== null,
654
+ oursExists: Boolean(info.stages[2]),
655
+ theirsExists: Boolean(info.stages[3]),
656
+ ours: info.ours.slice(0, DIFF_MAX_CHARS),
657
+ theirs: info.theirs.slice(0, DIFF_MAX_CHARS),
658
+ base: (info.base ?? '').slice(0, DIFF_MAX_CHARS),
659
+ }
660
+ }
661
+
662
+ /**
663
+ * 生成解决建议。**不写盘** —— 用户确认后走 applyConflict。
664
+ * 这样模型给出坏结果时最多是白看一次,不会把工作区改脏。
665
+ */
666
+ async function aiConflict(params, signal) {
667
+ const cwd = assertCwd(params.cwd)
668
+ const resolved = await repoRoot(cwd, signal)
669
+ const root = resolved.root
670
+ if (!root) {
671
+ throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
672
+ }
673
+ const path = assertRelPath(params.path)
674
+ const hint = typeof params.hint === 'string' ? params.hint.slice(0, 500) : ''
675
+ const info = await loadConflict(root, path, signal)
676
+ const total = info.ours.length + info.theirs.length + (info.base ? info.base.length : 0)
677
+ if (total > 240000) {
678
+ throw new GitError(`冲突文件过大(三份共 ${total} 字符),请手动解决`, { code: 'invalid-params' })
679
+ }
680
+ const context = ai() ?? {}
681
+ const branch = await currentBranch(root, signal)
682
+ const incoming = await incomingRef(root, signal)
683
+ const result = await resolveConflict({
684
+ path,
685
+ base: info.base,
686
+ ours: info.ours,
687
+ theirs: info.theirs,
688
+ branch,
689
+ incoming,
690
+ hint,
691
+ signal,
692
+ llm: context.llm,
693
+ selection: context.selection,
694
+ })
695
+ return {
696
+ path,
697
+ suggested: result.suggested,
698
+ model: result.model,
699
+ hasBase: info.base !== null,
700
+ }
701
+ }
702
+
703
+ /** 把解决结果写回工作区并标记为已解决。写入前再挡一次冲突标记。 */
704
+ async function applyConflict(params, signal) {
705
+ const cwd = assertCwd(params.cwd)
706
+ const resolved = await repoRoot(cwd, signal)
707
+ const root = resolved.root
708
+ if (!root) {
709
+ throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
710
+ }
711
+ const path = assertRelPath(params.path)
712
+ const content = params.content
713
+ if (typeof content !== 'string' || content.trim() === '') {
714
+ throw new GitError('content 不能为空', { code: 'invalid-params' })
715
+ }
716
+ if (content.length > DIFF_MAX_CHARS) {
717
+ throw new GitError(`content 过长(${content.length} 字符)`, { code: 'invalid-params' })
718
+ }
719
+ if (hasConflictMarkers(content)) {
720
+ throw new GitError('解决结果仍包含冲突标记,拒绝写入', { code: 'invalid-params' })
721
+ }
722
+ // 必须是当前真实的冲突文件,避免用这个接口去改任意文件
723
+ await loadConflict(root, path, signal)
724
+ const abs = join(root, path)
725
+ if (abs !== root && !abs.startsWith(root + '/')) {
726
+ throw new GitError('path 超出仓库范围', { code: 'invalid-params' })
727
+ }
728
+ await writeFileImpl(abs, content, 'utf8')
729
+ await runGit(['add', '--', path], { cwd: root, signal, timeoutMs: timeouts.write })
730
+ const unmerged = await unmergedCount(root, signal)
731
+ return { path, resolved: true, unmerged }
732
+ }
733
+
734
+ /**
735
+ * 直接取某一侧(ours / theirs)。
736
+ * 该侧没有这个文件时(被删除或改名)语义就是「删除」,用 git rm 收掉冲突条目。
737
+ */
738
+ async function takeSide(params, signal) {
739
+ const cwd = assertCwd(params.cwd)
740
+ const resolved = await repoRoot(cwd, signal)
741
+ const root = resolved.root
742
+ if (!root) {
743
+ throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
744
+ }
745
+ const path = assertRelPath(params.path)
746
+ const side = params.side
747
+ if (side !== 'ours' && side !== 'theirs') {
748
+ throw new GitError(`side 只能是 ours 或 theirs:${String(side)}`, { code: 'invalid-params' })
749
+ }
750
+ const info = await loadConflict(root, path, signal)
751
+ const stage = side === 'ours' ? 2 : 3
752
+ const deleted = !info.stages[stage]
753
+ if (deleted) {
754
+ await runGit(['rm', '-f', '--ignore-unmatch', '--', path], {
755
+ cwd: root, signal, timeoutMs: timeouts.write, allowFailure: true,
756
+ })
757
+ } else {
758
+ await runGit(['checkout', side === 'ours' ? '--ours' : '--theirs', '--', path], {
759
+ cwd: root, signal, timeoutMs: timeouts.write,
760
+ })
761
+ await runGit(['add', '--', path], { cwd: root, signal, timeoutMs: timeouts.write })
762
+ }
763
+ const unmerged = await unmergedCount(root, signal)
764
+ return { path, side, deleted, resolved: true, unmerged }
765
+ }
766
+
432
767
  async function aiCommit(params, signal) {
433
768
  const cwd = assertCwd(params.cwd)
434
769
  const resolved = await repoRoot(cwd, signal)
@@ -436,6 +771,10 @@ export function createOperations(deps) {
436
771
  if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
437
772
  const hint = typeof params.hint === 'string' ? params.hint.slice(0, 500) : ''
438
773
  const context = ai() ?? {}
774
+ const promptRes = await runGit(['config', '--get', COMMIT_PROMPT_KEY], {
775
+ cwd: root, signal, allowFailure: true,
776
+ })
777
+ const systemPrompt = promptRes.code === 0 ? promptRes.stdout : ''
439
778
  return generate({
440
779
  runGit: (args, options = {}) => runGit(args, {
441
780
  cwd: root,
@@ -448,6 +787,7 @@ export function createOperations(deps) {
448
787
  signal,
449
788
  llm: context.llm,
450
789
  selection: context.selection,
790
+ systemPrompt,
451
791
  })
452
792
  }
453
793
 
@@ -457,6 +797,7 @@ export function createOperations(deps) {
457
797
  diff,
458
798
  stage,
459
799
  unstage,
800
+ discard,
460
801
  switchBranch,
461
802
  deleteBranch,
462
803
  commit,
@@ -464,6 +805,14 @@ export function createOperations(deps) {
464
805
  pull,
465
806
  push,
466
807
  aiCommit,
808
+ stashPush,
809
+ stashPop,
810
+ stashDrop,
811
+ setCommitPrompt,
812
+ conflictInfo,
813
+ aiConflict,
814
+ applyConflict,
815
+ takeSide,
467
816
  }
468
817
 
469
818
  /**
@@ -479,7 +828,7 @@ export function createOperations(deps) {
479
828
  if (params === null || typeof params !== 'object') {
480
829
  throw new GitError('参数必须是 JSON 对象', { code: 'invalid-params' })
481
830
  }
482
- const timeout = op === 'aiCommit'
831
+ const timeout = op === 'aiCommit' || op === 'aiConflict'
483
832
  ? timeouts.ai
484
833
  : op === 'fetch' || op === 'pull' || op === 'push'
485
834
  ? timeouts.network
@@ -507,4 +856,4 @@ export function createOperations(deps) {
507
856
  }
508
857
 
509
858
  return { handle, ops: [...OPS], runGit }
510
- }
859
+ }