@caozeal/dsh-next-git 0.1.6 → 0.1.8
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 +8 -3
- package/lib/client.js +261 -19
- package/package.json +1 -1
- package/src/ai-conflict.js +159 -0
- package/src/operations.js +284 -3
package/README.md
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
>
|
|
5
5
|
> 原名 `dsh-plugin-git-manager`,2026-10 起包名改为 `@caozeal/dsh-next-git`(目录仍为 `dsh-next-git/`,功能不变)。
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## 45 秒介绍片
|
|
8
8
|
|
|
9
9
|
https://github.com/user-attachments/assets/c57169b4-df00-450d-b4f0-7a2c64db03b6
|
|
10
10
|
|
|
11
|
-
*全片界面为渲染示意,不含真实会话;[完整播放页](https://dsh
|
|
11
|
+
*全片界面为渲染示意,不含真实会话;[完整播放页](https://dsh.caozeal.cn/next-git/) 另有下载版。*
|
|
12
12
|
|
|
13
13
|
**不想先装再看?** [在线 Demo](https://dsh.caozeal.cn/next-git/demo/) 可以直接点——
|
|
14
14
|
它加载的是发布包里同一份 `lib/client.js`,只补了一个最小假宿主和一份真实仓库的录制快照
|
|
@@ -26,6 +26,10 @@ https://github.com/user-attachments/assets/c57169b4-df00-450d-b4f0-7a2c64db03b6
|
|
|
26
26
|
冲突文件不给丢弃——那等于放弃冲突解决状态,容易误伤。
|
|
27
27
|
- **Stash**:分支页底部可直接 stash 当前改动(有未跟踪文件时自动带 `-u`),
|
|
28
28
|
列出全部 stash,一键恢复(pop)或删除(drop)。
|
|
29
|
+
- **AI 解决冲突**:冲突文件点「解决」,AI 读索引里的 base / ours / theirs 三份版本,
|
|
30
|
+
给出一份合并后的完整文件——**只生成建议,确认后才写盘**;确认时再校验一遍有没有
|
|
31
|
+
残留 `<<<<<<<` 才 `git add`。机械场景可以直接「取当前分支 / 取对方分支」。
|
|
32
|
+
冲突文件刻意不提供「暂存」按钮:`git add` 会把带冲突标记的文件直接标记为已解决。
|
|
29
33
|
- **提交 / 推送 / 拉取**:Fetch、Pull、Push 按钮,提交信息输入框,
|
|
30
34
|
支持「提交」「提交并推送」;无 upstream 时自动 `push -u origin <branch>`。
|
|
31
35
|
- **AI 提交信息**:读取已暂存 diff + 最近提交风格,调用当前默认模型生成
|
|
@@ -197,5 +201,6 @@ op 一览:`snapshot` `graph` `diff` `stage` `unstage` `switchBranch` `deleteBr
|
|
|
197
201
|
- 大仓库的 graph 默认取 80 条(上限 300);diff 超过 400k 字符会截断。
|
|
198
202
|
- 会话的工作目录被删除/不可访问时,面板显示「工作目录不存在或无法访问」空态
|
|
199
203
|
(Host 返回 `cwd-unavailable`),不会展示原始 spawn 错误。
|
|
200
|
-
-
|
|
204
|
+
- AI 解决冲突按「单个文件」处理,不提供「一次性解决全部冲突」的批量操作;
|
|
205
|
+
多个文件仍要逐个确认(这是有意的,批量自动落盘风险太高)。
|
|
201
206
|
- 网络操作超时 180s;无凭据的私有仓库会失败并把 git stderr 显示出来。
|
package/lib/client.js
CHANGED
|
@@ -69,6 +69,21 @@ window.__ModuleLoader__.load({
|
|
|
69
69
|
'changes.discardConfirm': '丢弃 {path} 的改动?此操作不可恢复。',
|
|
70
70
|
'changes.discardUntrackedConfirm': '删除未跟踪文件 {path}?此操作不可恢复。',
|
|
71
71
|
'changes.discardStagedConfirm': '丢弃 {path} 的暂存与工作区改动?此操作不可恢复。',
|
|
72
|
+
'conflict.resolve': '解决冲突',
|
|
73
|
+
'conflict.title': '冲突解决',
|
|
74
|
+
'conflict.hintPlaceholder': '补充说明(可选),例如「版本号取上游」',
|
|
75
|
+
'conflict.ai': 'AI 生成解决建议',
|
|
76
|
+
'conflict.regenerate': '重新生成',
|
|
77
|
+
'conflict.takeOurs': '取当前分支',
|
|
78
|
+
'conflict.takeTheirs': '取对方分支',
|
|
79
|
+
'conflict.apply': '应用并标记已解决',
|
|
80
|
+
'conflict.discard': '放弃',
|
|
81
|
+
'conflict.empty': '先生成建议,或直接取某一侧;生成过程不会改动文件',
|
|
82
|
+
'conflict.generating': '正在生成解决建议…',
|
|
83
|
+
'conflict.preview': 'AI 建议的解决结果 · 确认后才会写入文件',
|
|
84
|
+
'conflict.model': '由 {model} 生成',
|
|
85
|
+
'conflict.remaining': '还剩 {n} 个冲突文件',
|
|
86
|
+
'conflict.noBase': 'add/add 冲突,没有共同祖先版本',
|
|
72
87
|
'commit.title': '提交信息',
|
|
73
88
|
'commit.placeholder': '填写提交信息,或先点「AI 生成信息」',
|
|
74
89
|
'commit.hintPlaceholder': '给 AI 的补充说明(可选)',
|
|
@@ -100,6 +115,10 @@ window.__ModuleLoader__.load({
|
|
|
100
115
|
'diff.deletions': '-{n}',
|
|
101
116
|
'diff.staged': '暂存区',
|
|
102
117
|
'diff.worktree': '工作区',
|
|
118
|
+
'diff.conflict': '冲突差异 · 当前分支 ↔ 合入分支',
|
|
119
|
+
'diff.conflictSame': '两边内容完全一致',
|
|
120
|
+
'diff.conflictDeleted': '该文件在合并的某一侧被删除',
|
|
121
|
+
'diff.conflictDeletedHint': '点右上角「解决冲突」决定保留还是删除',
|
|
103
122
|
'diff.commit': '提交 {hash}',
|
|
104
123
|
'diff.untracked': '新文件',
|
|
105
124
|
'status.loading': '加载中…',
|
|
@@ -130,6 +149,11 @@ window.__ModuleLoader__.load({
|
|
|
130
149
|
'status.stashDropOk': '已删除 stash@{n}',
|
|
131
150
|
'status.promptSaved': '提示词已保存',
|
|
132
151
|
'status.promptReset': '已恢复默认提示词',
|
|
152
|
+
'status.resolveOk': '已解决 {path}',
|
|
153
|
+
'status.resolveAllOk': '全部冲突已解决,可以提交了',
|
|
154
|
+
'status.takeOursOk': '已取当前分支版本',
|
|
155
|
+
'status.takeTheirsOk': '已取对方分支版本',
|
|
156
|
+
'status.conflictApplied': '已应用解决结果',
|
|
133
157
|
'status.aiModel': '由 {model} 生成',
|
|
134
158
|
'status.aiFallback': '模型输出不可用,已生成降级文案',
|
|
135
159
|
'toast.error': '失败',
|
|
@@ -206,6 +230,21 @@ window.__ModuleLoader__.load({
|
|
|
206
230
|
'changes.discardConfirm': 'Discard changes to {path}? This cannot be undone.',
|
|
207
231
|
'changes.discardUntrackedConfirm': 'Delete untracked file {path}? This cannot be undone.',
|
|
208
232
|
'changes.discardStagedConfirm': 'Discard staged and working-tree changes to {path}? This cannot be undone.',
|
|
233
|
+
'conflict.resolve': 'Resolve conflict',
|
|
234
|
+
'conflict.title': 'Resolve conflict',
|
|
235
|
+
'conflict.hintPlaceholder': 'Optional hint, e.g. "take the upstream version"',
|
|
236
|
+
'conflict.ai': 'Generate a resolution',
|
|
237
|
+
'conflict.regenerate': 'Regenerate',
|
|
238
|
+
'conflict.takeOurs': 'Take ours',
|
|
239
|
+
'conflict.takeTheirs': 'Take theirs',
|
|
240
|
+
'conflict.apply': 'Apply and mark resolved',
|
|
241
|
+
'conflict.discard': 'Discard',
|
|
242
|
+
'conflict.empty': 'Generate a suggestion, or take one side; generating never touches the file',
|
|
243
|
+
'conflict.generating': 'Generating a resolution…',
|
|
244
|
+
'conflict.preview': 'AI suggestion · nothing is written until you apply',
|
|
245
|
+
'conflict.model': 'Generated by {model}',
|
|
246
|
+
'conflict.remaining': '{n} conflicted file(s) left',
|
|
247
|
+
'conflict.noBase': 'add/add conflict; no common ancestor',
|
|
209
248
|
'commit.title': 'Commit message',
|
|
210
249
|
'commit.placeholder': 'Write a commit message, or generate one with AI',
|
|
211
250
|
'commit.hintPlaceholder': 'Optional hint for the AI',
|
|
@@ -237,6 +276,10 @@ window.__ModuleLoader__.load({
|
|
|
237
276
|
'diff.deletions': '-{n}',
|
|
238
277
|
'diff.staged': 'Staged',
|
|
239
278
|
'diff.worktree': 'Working tree',
|
|
279
|
+
'diff.conflict': 'Conflict · ours ↔ theirs',
|
|
280
|
+
'diff.conflictSame': 'Both sides are identical',
|
|
281
|
+
'diff.conflictDeleted': 'The file was deleted on one side of the merge',
|
|
282
|
+
'diff.conflictDeletedHint': 'Use "Resolve conflict" to keep or delete it',
|
|
240
283
|
'diff.commit': 'Commit {hash}',
|
|
241
284
|
'diff.untracked': 'New file',
|
|
242
285
|
'status.loading': 'Loading…',
|
|
@@ -267,6 +310,11 @@ window.__ModuleLoader__.load({
|
|
|
267
310
|
'status.stashDropOk': 'Dropped stash@{n}',
|
|
268
311
|
'status.promptSaved': 'Prompt saved',
|
|
269
312
|
'status.promptReset': 'Restored the default prompt',
|
|
313
|
+
'status.resolveOk': 'Resolved {path}',
|
|
314
|
+
'status.resolveAllOk': 'All conflicts resolved; ready to commit',
|
|
315
|
+
'status.takeOursOk': 'Took the ours side',
|
|
316
|
+
'status.takeTheirsOk': 'Took the theirs side',
|
|
317
|
+
'status.conflictApplied': 'Resolution applied',
|
|
270
318
|
'status.aiModel': 'Generated by {model}',
|
|
271
319
|
'status.aiFallback': 'Model output unusable; used a fallback message',
|
|
272
320
|
'toast.error': 'Failed',
|
|
@@ -463,6 +511,20 @@ window.__ModuleLoader__.load({
|
|
|
463
511
|
.gm-file:hover .gm-act, .gm-file:focus-within .gm-act, .gm-branch:hover .gm-act { opacity:1; }
|
|
464
512
|
.gm-act:hover { transform:scale(1.12); }
|
|
465
513
|
@media (hover:none) { .gm-act { opacity:1; } }
|
|
514
|
+
.gm-resolution { display:flex; flex-direction:column; gap:7px; padding:10px 12px 14px; }
|
|
515
|
+
.gm-res-head { display:flex; align-items:center; gap:6px; color:var(--gm-blue); }
|
|
516
|
+
.gm-res-title { font-size:11px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; }
|
|
517
|
+
.gm-res-path { font: 600 12px ui-monospace, SFMono-Regular, Menlo, monospace; color:var(--gm-fg);
|
|
518
|
+
padding:2px 0; word-break:break-all; }
|
|
519
|
+
.gm-res-note { font-size:11px; color:var(--gm-dim); line-height:1.6; }
|
|
520
|
+
/* .gm-input 默认 flex:1 1 100px,在纵向 flex 里会被拉成 100px 高,这里压回去 */
|
|
521
|
+
.gm-resolution .gm-input { flex:none; width:100%; height:28px; }
|
|
522
|
+
.gm-res-code { margin-top:4px; border-radius:10px; background:var(--gm-blue-06);
|
|
523
|
+
border:1px solid var(--gm-blue-12); padding:6px 0; overflow:auto; max-height:46vh; }
|
|
524
|
+
.gm-res-line { display:flex; gap:8px; font: 11.5px/1.65 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
525
|
+
white-space:pre; padding:0 8px; }
|
|
526
|
+
.gm-res-line .no { width:34px; text-align:right; color:var(--gm-dim); flex:none; user-select:none; }
|
|
527
|
+
.gm-res-line .tx { color:var(--gm-fg); }
|
|
466
528
|
.gm-conflict-bar { margin:9px 10px 2px; padding:6px 10px; border-radius:10px; font-size:11px; font-weight:600;
|
|
467
529
|
color:var(--gm-err); background:color-mix(in srgb, var(--gm-err) 8%, transparent);
|
|
468
530
|
border:1px solid color-mix(in srgb, var(--gm-err) 28%, transparent); }
|
|
@@ -855,6 +917,10 @@ window.__ModuleLoader__.load({
|
|
|
855
917
|
promptOpen: false,
|
|
856
918
|
promptDraft: '',
|
|
857
919
|
stashMessage: '',
|
|
920
|
+
// 冲突解决:resolution 非空时「差异」页切到解决视图。
|
|
921
|
+
// suggested 为 null 表示还没生成,用户可以先选边或让 AI 生成。
|
|
922
|
+
resolution: null,
|
|
923
|
+
conflictHint: '',
|
|
858
924
|
})
|
|
859
925
|
var toastTimer = null
|
|
860
926
|
var loadToken = 0
|
|
@@ -1161,6 +1227,70 @@ window.__ModuleLoader__.load({
|
|
|
1161
1227
|
}
|
|
1162
1228
|
}
|
|
1163
1229
|
|
|
1230
|
+
// ------------------------------------------------------------ 冲突解决
|
|
1231
|
+
|
|
1232
|
+
function openResolution(entry) {
|
|
1233
|
+
if (!entry || !entry.path) return
|
|
1234
|
+
set({
|
|
1235
|
+
resolution: { path: entry.path, suggested: null, model: null, loading: false, hasBase: true },
|
|
1236
|
+
conflictHint: '',
|
|
1237
|
+
activeTab: 'diff',
|
|
1238
|
+
selected: { kind: 'file', group: 'conflicted', path: entry.path },
|
|
1239
|
+
})
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function closeResolution() {
|
|
1243
|
+
set({ resolution: null, conflictHint: '' })
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function setConflictHint(value) {
|
|
1247
|
+
set({ conflictHint: String(value || '') })
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
async function aiResolve() {
|
|
1251
|
+
var state = store.get()
|
|
1252
|
+
var resolution = state.resolution
|
|
1253
|
+
if (!state.cwd || !resolution || resolution.loading) return
|
|
1254
|
+
set({ resolution: { path: resolution.path, suggested: resolution.suggested, model: resolution.model, loading: true, hasBase: resolution.hasBase } })
|
|
1255
|
+
var result = await runBusy('aiConflict', function () {
|
|
1256
|
+
return rpc('aiConflict', { cwd: state.cwd, path: resolution.path, hint: String(state.conflictHint || '').trim() })
|
|
1257
|
+
})
|
|
1258
|
+
if (result !== null) {
|
|
1259
|
+
set({ resolution: { path: resolution.path, suggested: result.suggested, model: result.model, loading: false, hasBase: result.hasBase } })
|
|
1260
|
+
} else {
|
|
1261
|
+
set({ resolution: { path: resolution.path, suggested: resolution.suggested, model: resolution.model, loading: false, hasBase: resolution.hasBase } })
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
async function applyResolution() {
|
|
1266
|
+
var state = store.get()
|
|
1267
|
+
var resolution = state.resolution
|
|
1268
|
+
if (!state.cwd || !resolution || !resolution.suggested) return
|
|
1269
|
+
var result = await runBusy('applyConflict', function () {
|
|
1270
|
+
return rpc('applyConflict', { cwd: state.cwd, path: resolution.path, content: resolution.suggested })
|
|
1271
|
+
})
|
|
1272
|
+
if (result !== null) {
|
|
1273
|
+
set({ resolution: null, conflictHint: '' })
|
|
1274
|
+
toast('ok', result.unmerged === 0 ? tr('status.resolveAllOk') : tr('status.conflictApplied'), result.output)
|
|
1275
|
+
await refresh()
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
async function takeConflictSide(side) {
|
|
1280
|
+
var state = store.get()
|
|
1281
|
+
var resolution = state.resolution
|
|
1282
|
+
if (!state.cwd || !resolution) return
|
|
1283
|
+
var result = await runBusy('takeSide', function () {
|
|
1284
|
+
return rpc('takeSide', { cwd: state.cwd, path: resolution.path, side: side })
|
|
1285
|
+
})
|
|
1286
|
+
if (result !== null) {
|
|
1287
|
+
set({ resolution: null, conflictHint: '' })
|
|
1288
|
+
var label = side === 'ours' ? tr('status.takeOursOk') : tr('status.takeTheirsOk')
|
|
1289
|
+
toast('ok', result.unmerged === 0 ? tr('status.resolveAllOk') : label, result.output)
|
|
1290
|
+
await refresh()
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1164
1294
|
async function switchBranch(name) {
|
|
1165
1295
|
var cwd = store.get().cwd
|
|
1166
1296
|
if (!cwd || !name) return
|
|
@@ -1295,6 +1425,12 @@ window.__ModuleLoader__.load({
|
|
|
1295
1425
|
setPromptDraft: setPromptDraft,
|
|
1296
1426
|
savePrompt: savePrompt,
|
|
1297
1427
|
resetPrompt: resetPrompt,
|
|
1428
|
+
openResolution: openResolution,
|
|
1429
|
+
closeResolution: closeResolution,
|
|
1430
|
+
setConflictHint: setConflictHint,
|
|
1431
|
+
aiResolve: aiResolve,
|
|
1432
|
+
applyResolution: applyResolution,
|
|
1433
|
+
takeConflictSide: takeConflictSide,
|
|
1298
1434
|
clearToast: clearToast,
|
|
1299
1435
|
}
|
|
1300
1436
|
}
|
|
@@ -1353,16 +1489,28 @@ window.__ModuleLoader__.load({
|
|
|
1353
1489
|
parts.dir ? h('span', { className: 'gm-dir' }, parts.dir) : null,
|
|
1354
1490
|
parts.base),
|
|
1355
1491
|
h('span', { className: 'gm-row', style: { gap: '2px' } },
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1492
|
+
// 冲突文件不给「暂存」:git add 会把带 <<<<<<< 的文件直接标记为已解决。
|
|
1493
|
+
// 唯一入口是「解决」,由 applyConflict / takeSide 负责清掉冲突标记。
|
|
1494
|
+
props.group === 'conflicted'
|
|
1495
|
+
? h('button', {
|
|
1496
|
+
type: 'button',
|
|
1497
|
+
className: 'gm-btn icon sm ghost gm-act',
|
|
1498
|
+
title: tr('conflict.resolve'),
|
|
1499
|
+
onClick: function (event) {
|
|
1500
|
+
event.stopPropagation()
|
|
1501
|
+
props.onResolve(entry)
|
|
1502
|
+
},
|
|
1503
|
+
}, h(Icon, { name: 'ai', size: 12 }))
|
|
1504
|
+
: h('button', {
|
|
1505
|
+
type: 'button',
|
|
1506
|
+
className: 'gm-btn icon sm ghost gm-act',
|
|
1507
|
+
title: staged ? tr('changes.unstage') : tr('changes.stage'),
|
|
1508
|
+
onClick: function (event) {
|
|
1509
|
+
event.stopPropagation()
|
|
1510
|
+
if (staged) props.onUnstage(entry)
|
|
1511
|
+
else props.onStage(entry)
|
|
1512
|
+
},
|
|
1513
|
+
}, h(Icon, { name: staged ? 'minus' : 'plus', size: 12 })),
|
|
1366
1514
|
// 冲突文件不给「丢弃」:那等于放弃冲突解决状态,语义上容易误伤
|
|
1367
1515
|
props.onDiscard && props.group !== 'conflicted'
|
|
1368
1516
|
? h('button', {
|
|
@@ -1398,6 +1546,7 @@ window.__ModuleLoader__.load({
|
|
|
1398
1546
|
onStage: function (entry) { props.actions.stage(group, entry) },
|
|
1399
1547
|
onUnstage: function (entry) { props.actions.unstage(group, entry) },
|
|
1400
1548
|
onDiscard: function (entry) { props.actions.discard(group, entry) },
|
|
1549
|
+
onResolve: function (entry) { props.actions.openResolution(entry) },
|
|
1401
1550
|
})
|
|
1402
1551
|
}
|
|
1403
1552
|
var empty = !conflicted.length && !staged.length && !unstaged.length && !untracked.length
|
|
@@ -1734,14 +1883,96 @@ window.__ModuleLoader__.load({
|
|
|
1734
1883
|
})
|
|
1735
1884
|
}
|
|
1736
1885
|
|
|
1886
|
+
/**
|
|
1887
|
+
* 冲突解决视图。
|
|
1888
|
+
* 生成阶段完全不碰磁盘,只有点「应用」才写文件 —— 模型给错了最多白看一次。
|
|
1889
|
+
*/
|
|
1890
|
+
function ResolutionView(props) {
|
|
1891
|
+
var state = props.state
|
|
1892
|
+
var resolution = state.resolution
|
|
1893
|
+
var busy = !!state.busy
|
|
1894
|
+
var suggested = resolution.suggested
|
|
1895
|
+
var lines = suggested ? suggested.replace(/\n$/, '').split('\n') : []
|
|
1896
|
+
var visible = lines.slice(0, 400)
|
|
1897
|
+
var actions = props.actions
|
|
1898
|
+
return h('div', { className: 'gm-scroll' },
|
|
1899
|
+
h('div', { className: 'gm-content-inner gm-resolution' },
|
|
1900
|
+
h('div', { className: 'gm-res-head' },
|
|
1901
|
+
h(Icon, { name: 'ai', size: 14 }),
|
|
1902
|
+
h('span', { className: 'gm-res-title' }, tr('conflict.title')),
|
|
1903
|
+
h('span', { className: 'gm-spacer' }),
|
|
1904
|
+
h('button', {
|
|
1905
|
+
type: 'button',
|
|
1906
|
+
className: 'gm-btn icon sm ghost',
|
|
1907
|
+
title: tr('conflict.discard'),
|
|
1908
|
+
onClick: function () { actions.closeResolution() },
|
|
1909
|
+
}, h(Icon, { name: 'close', size: 12 }))),
|
|
1910
|
+
h('div', { className: 'gm-res-path' }, resolution.path),
|
|
1911
|
+
resolution.loading
|
|
1912
|
+
? h('div', { className: 'gm-res-note' }, tr('conflict.generating'))
|
|
1913
|
+
: suggested
|
|
1914
|
+
? h('div', { className: 'gm-res-note' },
|
|
1915
|
+
tr('conflict.preview'),
|
|
1916
|
+
resolution.model ? ' · ' + tr('conflict.model', { model: resolution.model }) : '')
|
|
1917
|
+
: h('div', { className: 'gm-res-note' }, tr('conflict.empty')),
|
|
1918
|
+
h('input', {
|
|
1919
|
+
className: 'gm-input',
|
|
1920
|
+
value: state.conflictHint,
|
|
1921
|
+
placeholder: tr('conflict.hintPlaceholder'),
|
|
1922
|
+
disabled: busy,
|
|
1923
|
+
onChange: function (event) { actions.setConflictHint(event.target.value) },
|
|
1924
|
+
onKeyDown: function (event) { if (event.key === 'Enter') actions.aiResolve() },
|
|
1925
|
+
}),
|
|
1926
|
+
h('div', { className: 'gm-actions' },
|
|
1927
|
+
h(Btn, {
|
|
1928
|
+
icon: 'ai',
|
|
1929
|
+
label: suggested ? tr('conflict.regenerate') : tr('conflict.ai'),
|
|
1930
|
+
small: true,
|
|
1931
|
+
variant: 'primary',
|
|
1932
|
+
disabled: busy || resolution.loading,
|
|
1933
|
+
onClick: actions.aiResolve,
|
|
1934
|
+
}),
|
|
1935
|
+
h(Btn, {
|
|
1936
|
+
label: tr('conflict.takeOurs'),
|
|
1937
|
+
small: true,
|
|
1938
|
+
disabled: busy || resolution.loading,
|
|
1939
|
+
onClick: function () { actions.takeConflictSide('ours') },
|
|
1940
|
+
}),
|
|
1941
|
+
h(Btn, {
|
|
1942
|
+
label: tr('conflict.takeTheirs'),
|
|
1943
|
+
small: true,
|
|
1944
|
+
disabled: busy || resolution.loading,
|
|
1945
|
+
onClick: function () { actions.takeConflictSide('theirs') },
|
|
1946
|
+
})),
|
|
1947
|
+
suggested
|
|
1948
|
+
? h('div', { className: 'gm-actions' },
|
|
1949
|
+
h(Btn, { label: tr('conflict.apply'), small: true, variant: 'primary', disabled: busy, onClick: actions.applyResolution }),
|
|
1950
|
+
h(Btn, { label: tr('conflict.discard'), small: true, ghost: true, disabled: busy, onClick: function () { actions.closeResolution() } }))
|
|
1951
|
+
: null,
|
|
1952
|
+
suggested
|
|
1953
|
+
? h('div', { className: 'gm-res-code' }, visible.map(function (line, index) {
|
|
1954
|
+
return h('div', { key: 'rl:' + index, className: 'gm-res-line' },
|
|
1955
|
+
h('span', { className: 'no' }, String(index + 1)),
|
|
1956
|
+
h('span', { className: 'tx' }, line === '' ? ' ' : line))
|
|
1957
|
+
}))
|
|
1958
|
+
: null,
|
|
1959
|
+
suggested && lines.length > visible.length
|
|
1960
|
+
? h('div', { className: 'gm-res-note' }, tr('diff.truncated'))
|
|
1961
|
+
: null))
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1737
1964
|
function DiffView(props) {
|
|
1738
1965
|
var state = props.state
|
|
1966
|
+
// 解决视图优先:它是「差异」页的一个特殊态,不额外占一个 tab。
|
|
1967
|
+
if (state.resolution) return h(ResolutionView, { state: state, actions: props.actions })
|
|
1739
1968
|
var diff = state.diff
|
|
1740
1969
|
if (state.diffLoading) return h(EmptyState, { spinner: true, title: tr('status.loading') })
|
|
1741
1970
|
if (!diff) return h(EmptyState, { title: tr('diff.empty') })
|
|
1742
|
-
var title = diff.
|
|
1743
|
-
? tr('diff.
|
|
1744
|
-
: diff.mode === '
|
|
1971
|
+
var title = diff.conflict
|
|
1972
|
+
? tr('diff.conflict')
|
|
1973
|
+
: diff.mode === 'commit'
|
|
1974
|
+
? tr('diff.commit', { hash: state.selected && state.selected.hash ? state.selected.hash.slice(0, 8) : '' })
|
|
1975
|
+
: diff.mode === 'staged' ? tr('diff.staged') : diff.mode === 'untracked' ? tr('diff.untracked') : tr('diff.worktree')
|
|
1745
1976
|
return h('div', { className: 'gm-scroll' },
|
|
1746
1977
|
h('div', { className: 'gm-content-inner gm-diff' },
|
|
1747
1978
|
h('div', { className: 'gm-diff-head' },
|
|
@@ -1750,11 +1981,22 @@ window.__ModuleLoader__.load({
|
|
|
1750
1981
|
h('span', { className: 'gm-spacer' }),
|
|
1751
1982
|
h('span', { className: 'gm-badge ok' }, '+' + diff.additions),
|
|
1752
1983
|
h('span', { className: 'gm-badge err' }, '-' + diff.deletions),
|
|
1753
|
-
diff.truncated ? h('span', { className: 'gm-badge warn' }, tr('diff.truncatedShort')) : null
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1984
|
+
diff.truncated ? h('span', { className: 'gm-badge warn' }, tr('diff.truncatedShort')) : null,
|
|
1985
|
+
diff.conflict
|
|
1986
|
+
? h(Btn, {
|
|
1987
|
+
label: tr('conflict.resolve'),
|
|
1988
|
+
icon: 'ai',
|
|
1989
|
+
small: true,
|
|
1990
|
+
variant: 'primary',
|
|
1991
|
+
onClick: function () { props.actions.openResolution({ path: diff.path }) },
|
|
1992
|
+
})
|
|
1993
|
+
: null),
|
|
1994
|
+
diff.conflict && (!diff.oursExists || !diff.theirsExists)
|
|
1995
|
+
? h(EmptyState, { title: tr('diff.conflictDeleted'), hint: tr('diff.conflictDeletedHint') })
|
|
1996
|
+
: diff.binary
|
|
1997
|
+
? h(EmptyState, { title: tr('diff.binary') })
|
|
1998
|
+
: diff.hunks.length === 0
|
|
1999
|
+
? h(EmptyState, { title: diff.conflict ? tr('diff.conflictSame') : tr('diff.noText') })
|
|
1758
2000
|
: diff.hunks.map(function (hunk, hunkIndex) {
|
|
1759
2001
|
return h('div', { key: 'h:' + hunkIndex },
|
|
1760
2002
|
h('div', { className: 'gm-hunk' }, hunk.header),
|
|
@@ -1904,7 +2146,7 @@ window.__ModuleLoader__.load({
|
|
|
1904
2146
|
onStashDrop: props.actions.stashDrop,
|
|
1905
2147
|
onStashMessage: props.actions.setStashMessage,
|
|
1906
2148
|
})
|
|
1907
|
-
else content = h(DiffView, { state: state })
|
|
2149
|
+
else content = h(DiffView, { state: state, actions: props.actions })
|
|
1908
2150
|
return h('div', { className: 'gm-root' },
|
|
1909
2151
|
panelStyle(),
|
|
1910
2152
|
h(CompactTopBar, {
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
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
|
|
@@ -30,6 +34,7 @@ const OPS = [
|
|
|
30
34
|
'snapshot', 'graph', 'diff', 'stage', 'unstage', 'discard', 'switchBranch', 'deleteBranch',
|
|
31
35
|
'commit', 'fetch', 'pull', 'push', 'aiCommit',
|
|
32
36
|
'stashPush', 'stashPop', 'stashDrop', 'setCommitPrompt',
|
|
37
|
+
'conflictInfo', 'aiConflict', 'applyConflict', 'takeSide',
|
|
33
38
|
]
|
|
34
39
|
|
|
35
40
|
/** 自定义提交提示词在 git config 里的键。用 git config 而不是新增工作区文件:
|
|
@@ -67,6 +72,28 @@ function assertPaths(paths) {
|
|
|
67
72
|
})
|
|
68
73
|
}
|
|
69
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
|
+
|
|
70
97
|
function assertRef(value, label) {
|
|
71
98
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
72
99
|
throw new GitError(`缺少 ${label} 参数`, { code: 'invalid-params' })
|
|
@@ -99,6 +126,7 @@ export function createOperations(deps) {
|
|
|
99
126
|
const { exec } = deps
|
|
100
127
|
const ai = deps.ai ?? (() => null)
|
|
101
128
|
const generate = deps.generateCommitMessage ?? generateCommitMessage
|
|
129
|
+
const writeFileImpl = (deps.fileIO && deps.fileIO.writeFile) ?? writeFile
|
|
102
130
|
const timeouts = {
|
|
103
131
|
read: READ_TIMEOUT,
|
|
104
132
|
write: WRITE_TIMEOUT,
|
|
@@ -261,6 +289,14 @@ export function createOperations(deps) {
|
|
|
261
289
|
}
|
|
262
290
|
const path = typeof params.path === 'string' && params.path !== '' ? params.path : ''
|
|
263
291
|
const safePath = path !== '' && path.startsWith('-') ? `./${path}` : path
|
|
292
|
+
// 未合并的文件不能走普通 git diff:它给的是 combined diff(diff --cc / @@@ / ++ 前缀),
|
|
293
|
+
// 不是 unified diff,解析器读不出 hunk,面板会是空的。改成 diff 两个 stage 的 blob。
|
|
294
|
+
if (mode === 'worktree' && safePath !== '') {
|
|
295
|
+
const stages = await conflictStages(root, safePath, signal)
|
|
296
|
+
if (Object.keys(stages).length > 0) {
|
|
297
|
+
return await conflictDiff(root, safePath, stages, signal)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
264
300
|
/** @type {string[]} */
|
|
265
301
|
let args
|
|
266
302
|
if (mode === 'worktree') {
|
|
@@ -538,6 +574,247 @@ export function createOperations(deps) {
|
|
|
538
574
|
return { prompt: read.code === 0 ? read.stdout.trim() : '' }
|
|
539
575
|
}
|
|
540
576
|
|
|
577
|
+
// ---------------------------------------------------------------- 冲突解决
|
|
578
|
+
|
|
579
|
+
/** 列出某个冲突文件在索引里的三份 stage(1=base 2=ours 3=theirs)。 */
|
|
580
|
+
async function conflictStages(root, path, signal) {
|
|
581
|
+
const res = await runGit(['ls-files', '-u', '-z', '--', path], {
|
|
582
|
+
cwd: root, signal, allowFailure: true,
|
|
583
|
+
})
|
|
584
|
+
const stages = {}
|
|
585
|
+
for (const chunk of String(res.stdout || '').split('\0')) {
|
|
586
|
+
if (chunk === '') continue
|
|
587
|
+
const tab = chunk.indexOf('\t')
|
|
588
|
+
if (tab < 0) continue
|
|
589
|
+
// 格式:<mode> <hash> <stage>\t<path>
|
|
590
|
+
const meta = chunk.slice(0, tab).split(' ')
|
|
591
|
+
const hash = meta[1]
|
|
592
|
+
const stage = Number(meta[2])
|
|
593
|
+
if (!hash || !Number.isInteger(stage) || stage < 1 || stage > 3) continue
|
|
594
|
+
stages[stage] = hash
|
|
595
|
+
}
|
|
596
|
+
return stages
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function readBlob(root, hash, signal) {
|
|
600
|
+
if (!hash) return null
|
|
601
|
+
const res = await runGit(['cat-file', '-p', hash], { cwd: root, signal, allowFailure: true })
|
|
602
|
+
return res.code === 0 ? res.stdout : null
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* 冲突文件的差异视图:当前分支(:2:)↔ 合入分支(:3:)。
|
|
607
|
+
* 直接 `git diff <blob> <blob>` 是合法的,拿到的是普通 unified diff,行级高亮照样能用。
|
|
608
|
+
* 某一侧没有这个文件(删除/改名冲突)时没有可比的两份内容,只回元数据让面板说明情况。
|
|
609
|
+
*/
|
|
610
|
+
async function conflictDiff(root, path, stages, signal) {
|
|
611
|
+
const oursHash = stages[2]
|
|
612
|
+
const theirsHash = stages[3]
|
|
613
|
+
const base = {
|
|
614
|
+
mode: 'conflict',
|
|
615
|
+
path,
|
|
616
|
+
conflict: true,
|
|
617
|
+
oursExists: Boolean(oursHash),
|
|
618
|
+
theirsExists: Boolean(theirsHash),
|
|
619
|
+
binary: false,
|
|
620
|
+
additions: 0,
|
|
621
|
+
deletions: 0,
|
|
622
|
+
hunks: [],
|
|
623
|
+
truncated: false,
|
|
624
|
+
raw: '',
|
|
625
|
+
}
|
|
626
|
+
if (!oursHash || !theirsHash) return base
|
|
627
|
+
const args = ['diff', '--no-color', oursHash, theirsHash]
|
|
628
|
+
const result = await runGit(args, { cwd: root, signal, allowFailure: true })
|
|
629
|
+
if (result.code !== 0 && result.code !== 1) throw gitFailure(['git', ...args], result)
|
|
630
|
+
let raw = result.stdout
|
|
631
|
+
let truncated = false
|
|
632
|
+
if (raw.length > DIFF_MAX_CHARS) {
|
|
633
|
+
raw = raw.slice(0, DIFF_MAX_CHARS)
|
|
634
|
+
truncated = true
|
|
635
|
+
}
|
|
636
|
+
const parsed = parseUnifiedDiff(raw)
|
|
637
|
+
return {
|
|
638
|
+
...base,
|
|
639
|
+
binary: parsed.binary,
|
|
640
|
+
additions: parsed.additions,
|
|
641
|
+
deletions: parsed.deletions,
|
|
642
|
+
hunks: parsed.hunks,
|
|
643
|
+
truncated,
|
|
644
|
+
raw,
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** 还剩几个未解决冲突(applyConflict / takeSide 之后用它回执)。 */
|
|
649
|
+
async function unmergedCount(root, signal) {
|
|
650
|
+
const res = await runGit(['diff', '--name-only', '--diff-filter=U', '-z'], {
|
|
651
|
+
cwd: root, signal, allowFailure: true,
|
|
652
|
+
})
|
|
653
|
+
return String(res.stdout || '').split('\0').filter(Boolean).length
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** 合入侧的 ref 名:merge 看 MERGE_HEAD,rebase 看 REBASE_HEAD。 */
|
|
657
|
+
async function incomingRef(root, signal) {
|
|
658
|
+
for (const ref of ['MERGE_HEAD', 'REBASE_HEAD']) {
|
|
659
|
+
const exists = await runGit(['rev-parse', '--verify', '-q', ref], {
|
|
660
|
+
cwd: root, signal, allowFailure: true,
|
|
661
|
+
})
|
|
662
|
+
if (exists.code !== 0) continue
|
|
663
|
+
const name = await runGit(['name-rev', '--name-only', ref], {
|
|
664
|
+
cwd: root, signal, allowFailure: true,
|
|
665
|
+
})
|
|
666
|
+
if (name.code === 0 && name.stdout.trim() !== '') return name.stdout.trim()
|
|
667
|
+
const subject = await runGit(['log', '-1', '--pretty=%s', ref], {
|
|
668
|
+
cwd: root, signal, allowFailure: true,
|
|
669
|
+
})
|
|
670
|
+
if (subject.code === 0 && subject.stdout.trim() !== '') return subject.stdout.trim()
|
|
671
|
+
}
|
|
672
|
+
return null
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** 读一个冲突文件的三份版本;不是冲突文件就报 invalid-params。 */
|
|
676
|
+
async function loadConflict(root, path, signal) {
|
|
677
|
+
const stages = await conflictStages(root, path, signal)
|
|
678
|
+
if (Object.keys(stages).length === 0) {
|
|
679
|
+
throw new GitError(`该文件没有未解决的冲突:${path}`, { code: 'invalid-params' })
|
|
680
|
+
}
|
|
681
|
+
const [base, ours, theirs] = await Promise.all([
|
|
682
|
+
readBlob(root, stages[1], signal),
|
|
683
|
+
readBlob(root, stages[2], signal),
|
|
684
|
+
readBlob(root, stages[3], signal),
|
|
685
|
+
])
|
|
686
|
+
return { stages, base, ours: ours ?? '', theirs: theirs ?? '' }
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function conflictInfo(params, signal) {
|
|
690
|
+
const cwd = assertCwd(params.cwd)
|
|
691
|
+
const resolved = await repoRoot(cwd, signal)
|
|
692
|
+
const root = resolved.root
|
|
693
|
+
if (!root) {
|
|
694
|
+
throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
|
|
695
|
+
}
|
|
696
|
+
const path = assertRelPath(params.path)
|
|
697
|
+
const info = await loadConflict(root, path, signal)
|
|
698
|
+
const branch = await currentBranch(root, signal)
|
|
699
|
+
const incoming = await incomingRef(root, signal)
|
|
700
|
+
return {
|
|
701
|
+
path,
|
|
702
|
+
branch,
|
|
703
|
+
incoming,
|
|
704
|
+
hasBase: info.base !== null,
|
|
705
|
+
oursExists: Boolean(info.stages[2]),
|
|
706
|
+
theirsExists: Boolean(info.stages[3]),
|
|
707
|
+
ours: info.ours.slice(0, DIFF_MAX_CHARS),
|
|
708
|
+
theirs: info.theirs.slice(0, DIFF_MAX_CHARS),
|
|
709
|
+
base: (info.base ?? '').slice(0, DIFF_MAX_CHARS),
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* 生成解决建议。**不写盘** —— 用户确认后走 applyConflict。
|
|
715
|
+
* 这样模型给出坏结果时最多是白看一次,不会把工作区改脏。
|
|
716
|
+
*/
|
|
717
|
+
async function aiConflict(params, signal) {
|
|
718
|
+
const cwd = assertCwd(params.cwd)
|
|
719
|
+
const resolved = await repoRoot(cwd, signal)
|
|
720
|
+
const root = resolved.root
|
|
721
|
+
if (!root) {
|
|
722
|
+
throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
|
|
723
|
+
}
|
|
724
|
+
const path = assertRelPath(params.path)
|
|
725
|
+
const hint = typeof params.hint === 'string' ? params.hint.slice(0, 500) : ''
|
|
726
|
+
const info = await loadConflict(root, path, signal)
|
|
727
|
+
const total = info.ours.length + info.theirs.length + (info.base ? info.base.length : 0)
|
|
728
|
+
if (total > 240000) {
|
|
729
|
+
throw new GitError(`冲突文件过大(三份共 ${total} 字符),请手动解决`, { code: 'invalid-params' })
|
|
730
|
+
}
|
|
731
|
+
const context = ai() ?? {}
|
|
732
|
+
const branch = await currentBranch(root, signal)
|
|
733
|
+
const incoming = await incomingRef(root, signal)
|
|
734
|
+
const result = await resolveConflict({
|
|
735
|
+
path,
|
|
736
|
+
base: info.base,
|
|
737
|
+
ours: info.ours,
|
|
738
|
+
theirs: info.theirs,
|
|
739
|
+
branch,
|
|
740
|
+
incoming,
|
|
741
|
+
hint,
|
|
742
|
+
signal,
|
|
743
|
+
llm: context.llm,
|
|
744
|
+
selection: context.selection,
|
|
745
|
+
})
|
|
746
|
+
return {
|
|
747
|
+
path,
|
|
748
|
+
suggested: result.suggested,
|
|
749
|
+
model: result.model,
|
|
750
|
+
hasBase: info.base !== null,
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** 把解决结果写回工作区并标记为已解决。写入前再挡一次冲突标记。 */
|
|
755
|
+
async function applyConflict(params, signal) {
|
|
756
|
+
const cwd = assertCwd(params.cwd)
|
|
757
|
+
const resolved = await repoRoot(cwd, signal)
|
|
758
|
+
const root = resolved.root
|
|
759
|
+
if (!root) {
|
|
760
|
+
throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
|
|
761
|
+
}
|
|
762
|
+
const path = assertRelPath(params.path)
|
|
763
|
+
const content = params.content
|
|
764
|
+
if (typeof content !== 'string' || content.trim() === '') {
|
|
765
|
+
throw new GitError('content 不能为空', { code: 'invalid-params' })
|
|
766
|
+
}
|
|
767
|
+
if (content.length > DIFF_MAX_CHARS) {
|
|
768
|
+
throw new GitError(`content 过长(${content.length} 字符)`, { code: 'invalid-params' })
|
|
769
|
+
}
|
|
770
|
+
if (hasConflictMarkers(content)) {
|
|
771
|
+
throw new GitError('解决结果仍包含冲突标记,拒绝写入', { code: 'invalid-params' })
|
|
772
|
+
}
|
|
773
|
+
// 必须是当前真实的冲突文件,避免用这个接口去改任意文件
|
|
774
|
+
await loadConflict(root, path, signal)
|
|
775
|
+
const abs = join(root, path)
|
|
776
|
+
if (abs !== root && !abs.startsWith(root + '/')) {
|
|
777
|
+
throw new GitError('path 超出仓库范围', { code: 'invalid-params' })
|
|
778
|
+
}
|
|
779
|
+
await writeFileImpl(abs, content, 'utf8')
|
|
780
|
+
await runGit(['add', '--', path], { cwd: root, signal, timeoutMs: timeouts.write })
|
|
781
|
+
const unmerged = await unmergedCount(root, signal)
|
|
782
|
+
return { path, resolved: true, unmerged }
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* 直接取某一侧(ours / theirs)。
|
|
787
|
+
* 该侧没有这个文件时(被删除或改名)语义就是「删除」,用 git rm 收掉冲突条目。
|
|
788
|
+
*/
|
|
789
|
+
async function takeSide(params, signal) {
|
|
790
|
+
const cwd = assertCwd(params.cwd)
|
|
791
|
+
const resolved = await repoRoot(cwd, signal)
|
|
792
|
+
const root = resolved.root
|
|
793
|
+
if (!root) {
|
|
794
|
+
throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
|
|
795
|
+
}
|
|
796
|
+
const path = assertRelPath(params.path)
|
|
797
|
+
const side = params.side
|
|
798
|
+
if (side !== 'ours' && side !== 'theirs') {
|
|
799
|
+
throw new GitError(`side 只能是 ours 或 theirs:${String(side)}`, { code: 'invalid-params' })
|
|
800
|
+
}
|
|
801
|
+
const info = await loadConflict(root, path, signal)
|
|
802
|
+
const stage = side === 'ours' ? 2 : 3
|
|
803
|
+
const deleted = !info.stages[stage]
|
|
804
|
+
if (deleted) {
|
|
805
|
+
await runGit(['rm', '-f', '--ignore-unmatch', '--', path], {
|
|
806
|
+
cwd: root, signal, timeoutMs: timeouts.write, allowFailure: true,
|
|
807
|
+
})
|
|
808
|
+
} else {
|
|
809
|
+
await runGit(['checkout', side === 'ours' ? '--ours' : '--theirs', '--', path], {
|
|
810
|
+
cwd: root, signal, timeoutMs: timeouts.write,
|
|
811
|
+
})
|
|
812
|
+
await runGit(['add', '--', path], { cwd: root, signal, timeoutMs: timeouts.write })
|
|
813
|
+
}
|
|
814
|
+
const unmerged = await unmergedCount(root, signal)
|
|
815
|
+
return { path, side, deleted, resolved: true, unmerged }
|
|
816
|
+
}
|
|
817
|
+
|
|
541
818
|
async function aiCommit(params, signal) {
|
|
542
819
|
const cwd = assertCwd(params.cwd)
|
|
543
820
|
const resolved = await repoRoot(cwd, signal)
|
|
@@ -583,6 +860,10 @@ export function createOperations(deps) {
|
|
|
583
860
|
stashPop,
|
|
584
861
|
stashDrop,
|
|
585
862
|
setCommitPrompt,
|
|
863
|
+
conflictInfo,
|
|
864
|
+
aiConflict,
|
|
865
|
+
applyConflict,
|
|
866
|
+
takeSide,
|
|
586
867
|
}
|
|
587
868
|
|
|
588
869
|
/**
|
|
@@ -598,7 +879,7 @@ export function createOperations(deps) {
|
|
|
598
879
|
if (params === null || typeof params !== 'object') {
|
|
599
880
|
throw new GitError('参数必须是 JSON 对象', { code: 'invalid-params' })
|
|
600
881
|
}
|
|
601
|
-
const timeout = op === 'aiCommit'
|
|
882
|
+
const timeout = op === 'aiCommit' || op === 'aiConflict'
|
|
602
883
|
? timeouts.ai
|
|
603
884
|
: op === 'fetch' || op === 'pull' || op === 'push'
|
|
604
885
|
? timeouts.network
|