@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/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
|
+
}
|