@caozeal/dsh-next-git 0.1.6 → 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 +8 -3
- package/lib/client.js +232 -11
- package/package.json +1 -1
- package/src/ai-conflict.js +159 -0
- package/src/operations.js +233 -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
|
+
## 41 秒介绍片
|
|
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 的补充说明(可选)',
|
|
@@ -130,6 +145,11 @@ window.__ModuleLoader__.load({
|
|
|
130
145
|
'status.stashDropOk': '已删除 stash@{n}',
|
|
131
146
|
'status.promptSaved': '提示词已保存',
|
|
132
147
|
'status.promptReset': '已恢复默认提示词',
|
|
148
|
+
'status.resolveOk': '已解决 {path}',
|
|
149
|
+
'status.resolveAllOk': '全部冲突已解决,可以提交了',
|
|
150
|
+
'status.takeOursOk': '已取当前分支版本',
|
|
151
|
+
'status.takeTheirsOk': '已取对方分支版本',
|
|
152
|
+
'status.conflictApplied': '已应用解决结果',
|
|
133
153
|
'status.aiModel': '由 {model} 生成',
|
|
134
154
|
'status.aiFallback': '模型输出不可用,已生成降级文案',
|
|
135
155
|
'toast.error': '失败',
|
|
@@ -206,6 +226,21 @@ window.__ModuleLoader__.load({
|
|
|
206
226
|
'changes.discardConfirm': 'Discard changes to {path}? This cannot be undone.',
|
|
207
227
|
'changes.discardUntrackedConfirm': 'Delete untracked file {path}? This cannot be undone.',
|
|
208
228
|
'changes.discardStagedConfirm': 'Discard staged and working-tree changes to {path}? This cannot be undone.',
|
|
229
|
+
'conflict.resolve': 'Resolve conflict',
|
|
230
|
+
'conflict.title': 'Resolve conflict',
|
|
231
|
+
'conflict.hintPlaceholder': 'Optional hint, e.g. "take the upstream version"',
|
|
232
|
+
'conflict.ai': 'Generate a resolution',
|
|
233
|
+
'conflict.regenerate': 'Regenerate',
|
|
234
|
+
'conflict.takeOurs': 'Take ours',
|
|
235
|
+
'conflict.takeTheirs': 'Take theirs',
|
|
236
|
+
'conflict.apply': 'Apply and mark resolved',
|
|
237
|
+
'conflict.discard': 'Discard',
|
|
238
|
+
'conflict.empty': 'Generate a suggestion, or take one side; generating never touches the file',
|
|
239
|
+
'conflict.generating': 'Generating a resolution…',
|
|
240
|
+
'conflict.preview': 'AI suggestion · nothing is written until you apply',
|
|
241
|
+
'conflict.model': 'Generated by {model}',
|
|
242
|
+
'conflict.remaining': '{n} conflicted file(s) left',
|
|
243
|
+
'conflict.noBase': 'add/add conflict; no common ancestor',
|
|
209
244
|
'commit.title': 'Commit message',
|
|
210
245
|
'commit.placeholder': 'Write a commit message, or generate one with AI',
|
|
211
246
|
'commit.hintPlaceholder': 'Optional hint for the AI',
|
|
@@ -267,6 +302,11 @@ window.__ModuleLoader__.load({
|
|
|
267
302
|
'status.stashDropOk': 'Dropped stash@{n}',
|
|
268
303
|
'status.promptSaved': 'Prompt saved',
|
|
269
304
|
'status.promptReset': 'Restored the default prompt',
|
|
305
|
+
'status.resolveOk': 'Resolved {path}',
|
|
306
|
+
'status.resolveAllOk': 'All conflicts resolved; ready to commit',
|
|
307
|
+
'status.takeOursOk': 'Took the ours side',
|
|
308
|
+
'status.takeTheirsOk': 'Took the theirs side',
|
|
309
|
+
'status.conflictApplied': 'Resolution applied',
|
|
270
310
|
'status.aiModel': 'Generated by {model}',
|
|
271
311
|
'status.aiFallback': 'Model output unusable; used a fallback message',
|
|
272
312
|
'toast.error': 'Failed',
|
|
@@ -463,6 +503,20 @@ window.__ModuleLoader__.load({
|
|
|
463
503
|
.gm-file:hover .gm-act, .gm-file:focus-within .gm-act, .gm-branch:hover .gm-act { opacity:1; }
|
|
464
504
|
.gm-act:hover { transform:scale(1.12); }
|
|
465
505
|
@media (hover:none) { .gm-act { opacity:1; } }
|
|
506
|
+
.gm-resolution { display:flex; flex-direction:column; gap:7px; padding:10px 12px 14px; }
|
|
507
|
+
.gm-res-head { display:flex; align-items:center; gap:6px; color:var(--gm-blue); }
|
|
508
|
+
.gm-res-title { font-size:11px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; }
|
|
509
|
+
.gm-res-path { font: 600 12px ui-monospace, SFMono-Regular, Menlo, monospace; color:var(--gm-fg);
|
|
510
|
+
padding:2px 0; word-break:break-all; }
|
|
511
|
+
.gm-res-note { font-size:11px; color:var(--gm-dim); line-height:1.6; }
|
|
512
|
+
/* .gm-input 默认 flex:1 1 100px,在纵向 flex 里会被拉成 100px 高,这里压回去 */
|
|
513
|
+
.gm-resolution .gm-input { flex:none; width:100%; height:28px; }
|
|
514
|
+
.gm-res-code { margin-top:4px; border-radius:10px; background:var(--gm-blue-06);
|
|
515
|
+
border:1px solid var(--gm-blue-12); padding:6px 0; overflow:auto; max-height:46vh; }
|
|
516
|
+
.gm-res-line { display:flex; gap:8px; font: 11.5px/1.65 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
517
|
+
white-space:pre; padding:0 8px; }
|
|
518
|
+
.gm-res-line .no { width:34px; text-align:right; color:var(--gm-dim); flex:none; user-select:none; }
|
|
519
|
+
.gm-res-line .tx { color:var(--gm-fg); }
|
|
466
520
|
.gm-conflict-bar { margin:9px 10px 2px; padding:6px 10px; border-radius:10px; font-size:11px; font-weight:600;
|
|
467
521
|
color:var(--gm-err); background:color-mix(in srgb, var(--gm-err) 8%, transparent);
|
|
468
522
|
border:1px solid color-mix(in srgb, var(--gm-err) 28%, transparent); }
|
|
@@ -855,6 +909,10 @@ window.__ModuleLoader__.load({
|
|
|
855
909
|
promptOpen: false,
|
|
856
910
|
promptDraft: '',
|
|
857
911
|
stashMessage: '',
|
|
912
|
+
// 冲突解决:resolution 非空时「差异」页切到解决视图。
|
|
913
|
+
// suggested 为 null 表示还没生成,用户可以先选边或让 AI 生成。
|
|
914
|
+
resolution: null,
|
|
915
|
+
conflictHint: '',
|
|
858
916
|
})
|
|
859
917
|
var toastTimer = null
|
|
860
918
|
var loadToken = 0
|
|
@@ -1161,6 +1219,70 @@ window.__ModuleLoader__.load({
|
|
|
1161
1219
|
}
|
|
1162
1220
|
}
|
|
1163
1221
|
|
|
1222
|
+
// ------------------------------------------------------------ 冲突解决
|
|
1223
|
+
|
|
1224
|
+
function openResolution(entry) {
|
|
1225
|
+
if (!entry || !entry.path) return
|
|
1226
|
+
set({
|
|
1227
|
+
resolution: { path: entry.path, suggested: null, model: null, loading: false, hasBase: true },
|
|
1228
|
+
conflictHint: '',
|
|
1229
|
+
activeTab: 'diff',
|
|
1230
|
+
selected: { kind: 'file', group: 'conflicted', path: entry.path },
|
|
1231
|
+
})
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function closeResolution() {
|
|
1235
|
+
set({ resolution: null, conflictHint: '' })
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function setConflictHint(value) {
|
|
1239
|
+
set({ conflictHint: String(value || '') })
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
async function aiResolve() {
|
|
1243
|
+
var state = store.get()
|
|
1244
|
+
var resolution = state.resolution
|
|
1245
|
+
if (!state.cwd || !resolution || resolution.loading) return
|
|
1246
|
+
set({ resolution: { path: resolution.path, suggested: resolution.suggested, model: resolution.model, loading: true, hasBase: resolution.hasBase } })
|
|
1247
|
+
var result = await runBusy('aiConflict', function () {
|
|
1248
|
+
return rpc('aiConflict', { cwd: state.cwd, path: resolution.path, hint: String(state.conflictHint || '').trim() })
|
|
1249
|
+
})
|
|
1250
|
+
if (result !== null) {
|
|
1251
|
+
set({ resolution: { path: resolution.path, suggested: result.suggested, model: result.model, loading: false, hasBase: result.hasBase } })
|
|
1252
|
+
} else {
|
|
1253
|
+
set({ resolution: { path: resolution.path, suggested: resolution.suggested, model: resolution.model, loading: false, hasBase: resolution.hasBase } })
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
async function applyResolution() {
|
|
1258
|
+
var state = store.get()
|
|
1259
|
+
var resolution = state.resolution
|
|
1260
|
+
if (!state.cwd || !resolution || !resolution.suggested) return
|
|
1261
|
+
var result = await runBusy('applyConflict', function () {
|
|
1262
|
+
return rpc('applyConflict', { cwd: state.cwd, path: resolution.path, content: resolution.suggested })
|
|
1263
|
+
})
|
|
1264
|
+
if (result !== null) {
|
|
1265
|
+
set({ resolution: null, conflictHint: '' })
|
|
1266
|
+
toast('ok', result.unmerged === 0 ? tr('status.resolveAllOk') : tr('status.conflictApplied'), result.output)
|
|
1267
|
+
await refresh()
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
async function takeConflictSide(side) {
|
|
1272
|
+
var state = store.get()
|
|
1273
|
+
var resolution = state.resolution
|
|
1274
|
+
if (!state.cwd || !resolution) return
|
|
1275
|
+
var result = await runBusy('takeSide', function () {
|
|
1276
|
+
return rpc('takeSide', { cwd: state.cwd, path: resolution.path, side: side })
|
|
1277
|
+
})
|
|
1278
|
+
if (result !== null) {
|
|
1279
|
+
set({ resolution: null, conflictHint: '' })
|
|
1280
|
+
var label = side === 'ours' ? tr('status.takeOursOk') : tr('status.takeTheirsOk')
|
|
1281
|
+
toast('ok', result.unmerged === 0 ? tr('status.resolveAllOk') : label, result.output)
|
|
1282
|
+
await refresh()
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1164
1286
|
async function switchBranch(name) {
|
|
1165
1287
|
var cwd = store.get().cwd
|
|
1166
1288
|
if (!cwd || !name) return
|
|
@@ -1295,6 +1417,12 @@ window.__ModuleLoader__.load({
|
|
|
1295
1417
|
setPromptDraft: setPromptDraft,
|
|
1296
1418
|
savePrompt: savePrompt,
|
|
1297
1419
|
resetPrompt: resetPrompt,
|
|
1420
|
+
openResolution: openResolution,
|
|
1421
|
+
closeResolution: closeResolution,
|
|
1422
|
+
setConflictHint: setConflictHint,
|
|
1423
|
+
aiResolve: aiResolve,
|
|
1424
|
+
applyResolution: applyResolution,
|
|
1425
|
+
takeConflictSide: takeConflictSide,
|
|
1298
1426
|
clearToast: clearToast,
|
|
1299
1427
|
}
|
|
1300
1428
|
}
|
|
@@ -1353,16 +1481,28 @@ window.__ModuleLoader__.load({
|
|
|
1353
1481
|
parts.dir ? h('span', { className: 'gm-dir' }, parts.dir) : null,
|
|
1354
1482
|
parts.base),
|
|
1355
1483
|
h('span', { className: 'gm-row', style: { gap: '2px' } },
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1484
|
+
// 冲突文件不给「暂存」:git add 会把带 <<<<<<< 的文件直接标记为已解决。
|
|
1485
|
+
// 唯一入口是「解决」,由 applyConflict / takeSide 负责清掉冲突标记。
|
|
1486
|
+
props.group === 'conflicted'
|
|
1487
|
+
? h('button', {
|
|
1488
|
+
type: 'button',
|
|
1489
|
+
className: 'gm-btn icon sm ghost gm-act',
|
|
1490
|
+
title: tr('conflict.resolve'),
|
|
1491
|
+
onClick: function (event) {
|
|
1492
|
+
event.stopPropagation()
|
|
1493
|
+
props.onResolve(entry)
|
|
1494
|
+
},
|
|
1495
|
+
}, h(Icon, { name: 'ai', size: 12 }))
|
|
1496
|
+
: h('button', {
|
|
1497
|
+
type: 'button',
|
|
1498
|
+
className: 'gm-btn icon sm ghost gm-act',
|
|
1499
|
+
title: staged ? tr('changes.unstage') : tr('changes.stage'),
|
|
1500
|
+
onClick: function (event) {
|
|
1501
|
+
event.stopPropagation()
|
|
1502
|
+
if (staged) props.onUnstage(entry)
|
|
1503
|
+
else props.onStage(entry)
|
|
1504
|
+
},
|
|
1505
|
+
}, h(Icon, { name: staged ? 'minus' : 'plus', size: 12 })),
|
|
1366
1506
|
// 冲突文件不给「丢弃」:那等于放弃冲突解决状态,语义上容易误伤
|
|
1367
1507
|
props.onDiscard && props.group !== 'conflicted'
|
|
1368
1508
|
? h('button', {
|
|
@@ -1398,6 +1538,7 @@ window.__ModuleLoader__.load({
|
|
|
1398
1538
|
onStage: function (entry) { props.actions.stage(group, entry) },
|
|
1399
1539
|
onUnstage: function (entry) { props.actions.unstage(group, entry) },
|
|
1400
1540
|
onDiscard: function (entry) { props.actions.discard(group, entry) },
|
|
1541
|
+
onResolve: function (entry) { props.actions.openResolution(entry) },
|
|
1401
1542
|
})
|
|
1402
1543
|
}
|
|
1403
1544
|
var empty = !conflicted.length && !staged.length && !unstaged.length && !untracked.length
|
|
@@ -1734,8 +1875,88 @@ window.__ModuleLoader__.load({
|
|
|
1734
1875
|
})
|
|
1735
1876
|
}
|
|
1736
1877
|
|
|
1878
|
+
/**
|
|
1879
|
+
* 冲突解决视图。
|
|
1880
|
+
* 生成阶段完全不碰磁盘,只有点「应用」才写文件 —— 模型给错了最多白看一次。
|
|
1881
|
+
*/
|
|
1882
|
+
function ResolutionView(props) {
|
|
1883
|
+
var state = props.state
|
|
1884
|
+
var resolution = state.resolution
|
|
1885
|
+
var busy = !!state.busy
|
|
1886
|
+
var suggested = resolution.suggested
|
|
1887
|
+
var lines = suggested ? suggested.replace(/\n$/, '').split('\n') : []
|
|
1888
|
+
var visible = lines.slice(0, 400)
|
|
1889
|
+
var actions = props.actions
|
|
1890
|
+
return h('div', { className: 'gm-scroll' },
|
|
1891
|
+
h('div', { className: 'gm-content-inner gm-resolution' },
|
|
1892
|
+
h('div', { className: 'gm-res-head' },
|
|
1893
|
+
h(Icon, { name: 'ai', size: 14 }),
|
|
1894
|
+
h('span', { className: 'gm-res-title' }, tr('conflict.title')),
|
|
1895
|
+
h('span', { className: 'gm-spacer' }),
|
|
1896
|
+
h('button', {
|
|
1897
|
+
type: 'button',
|
|
1898
|
+
className: 'gm-btn icon sm ghost',
|
|
1899
|
+
title: tr('conflict.discard'),
|
|
1900
|
+
onClick: function () { actions.closeResolution() },
|
|
1901
|
+
}, h(Icon, { name: 'close', size: 12 }))),
|
|
1902
|
+
h('div', { className: 'gm-res-path' }, resolution.path),
|
|
1903
|
+
resolution.loading
|
|
1904
|
+
? h('div', { className: 'gm-res-note' }, tr('conflict.generating'))
|
|
1905
|
+
: suggested
|
|
1906
|
+
? h('div', { className: 'gm-res-note' },
|
|
1907
|
+
tr('conflict.preview'),
|
|
1908
|
+
resolution.model ? ' · ' + tr('conflict.model', { model: resolution.model }) : '')
|
|
1909
|
+
: h('div', { className: 'gm-res-note' }, tr('conflict.empty')),
|
|
1910
|
+
h('input', {
|
|
1911
|
+
className: 'gm-input',
|
|
1912
|
+
value: state.conflictHint,
|
|
1913
|
+
placeholder: tr('conflict.hintPlaceholder'),
|
|
1914
|
+
disabled: busy,
|
|
1915
|
+
onChange: function (event) { actions.setConflictHint(event.target.value) },
|
|
1916
|
+
onKeyDown: function (event) { if (event.key === 'Enter') actions.aiResolve() },
|
|
1917
|
+
}),
|
|
1918
|
+
h('div', { className: 'gm-actions' },
|
|
1919
|
+
h(Btn, {
|
|
1920
|
+
icon: 'ai',
|
|
1921
|
+
label: suggested ? tr('conflict.regenerate') : tr('conflict.ai'),
|
|
1922
|
+
small: true,
|
|
1923
|
+
variant: 'primary',
|
|
1924
|
+
disabled: busy || resolution.loading,
|
|
1925
|
+
onClick: actions.aiResolve,
|
|
1926
|
+
}),
|
|
1927
|
+
h(Btn, {
|
|
1928
|
+
label: tr('conflict.takeOurs'),
|
|
1929
|
+
small: true,
|
|
1930
|
+
disabled: busy || resolution.loading,
|
|
1931
|
+
onClick: function () { actions.takeConflictSide('ours') },
|
|
1932
|
+
}),
|
|
1933
|
+
h(Btn, {
|
|
1934
|
+
label: tr('conflict.takeTheirs'),
|
|
1935
|
+
small: true,
|
|
1936
|
+
disabled: busy || resolution.loading,
|
|
1937
|
+
onClick: function () { actions.takeConflictSide('theirs') },
|
|
1938
|
+
})),
|
|
1939
|
+
suggested
|
|
1940
|
+
? h('div', { className: 'gm-actions' },
|
|
1941
|
+
h(Btn, { label: tr('conflict.apply'), small: true, variant: 'primary', disabled: busy, onClick: actions.applyResolution }),
|
|
1942
|
+
h(Btn, { label: tr('conflict.discard'), small: true, ghost: true, disabled: busy, onClick: function () { actions.closeResolution() } }))
|
|
1943
|
+
: null,
|
|
1944
|
+
suggested
|
|
1945
|
+
? h('div', { className: 'gm-res-code' }, visible.map(function (line, index) {
|
|
1946
|
+
return h('div', { key: 'rl:' + index, className: 'gm-res-line' },
|
|
1947
|
+
h('span', { className: 'no' }, String(index + 1)),
|
|
1948
|
+
h('span', { className: 'tx' }, line === '' ? ' ' : line))
|
|
1949
|
+
}))
|
|
1950
|
+
: null,
|
|
1951
|
+
suggested && lines.length > visible.length
|
|
1952
|
+
? h('div', { className: 'gm-res-note' }, tr('diff.truncated'))
|
|
1953
|
+
: null))
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1737
1956
|
function DiffView(props) {
|
|
1738
1957
|
var state = props.state
|
|
1958
|
+
// 解决视图优先:它是「差异」页的一个特殊态,不额外占一个 tab。
|
|
1959
|
+
if (state.resolution) return h(ResolutionView, { state: state, actions: props.actions })
|
|
1739
1960
|
var diff = state.diff
|
|
1740
1961
|
if (state.diffLoading) return h(EmptyState, { spinner: true, title: tr('status.loading') })
|
|
1741
1962
|
if (!diff) return h(EmptyState, { title: tr('diff.empty') })
|
|
@@ -1904,7 +2125,7 @@ window.__ModuleLoader__.load({
|
|
|
1904
2125
|
onStashDrop: props.actions.stashDrop,
|
|
1905
2126
|
onStashMessage: props.actions.setStashMessage,
|
|
1906
2127
|
})
|
|
1907
|
-
else content = h(DiffView, { state: state })
|
|
2128
|
+
else content = h(DiffView, { state: state, actions: props.actions })
|
|
1908
2129
|
return h('div', { className: 'gm-root' },
|
|
1909
2130
|
panelStyle(),
|
|
1910
2131
|
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,
|
|
@@ -538,6 +566,204 @@ export function createOperations(deps) {
|
|
|
538
566
|
return { prompt: read.code === 0 ? read.stdout.trim() : '' }
|
|
539
567
|
}
|
|
540
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
|
+
|
|
541
767
|
async function aiCommit(params, signal) {
|
|
542
768
|
const cwd = assertCwd(params.cwd)
|
|
543
769
|
const resolved = await repoRoot(cwd, signal)
|
|
@@ -583,6 +809,10 @@ export function createOperations(deps) {
|
|
|
583
809
|
stashPop,
|
|
584
810
|
stashDrop,
|
|
585
811
|
setCommitPrompt,
|
|
812
|
+
conflictInfo,
|
|
813
|
+
aiConflict,
|
|
814
|
+
applyConflict,
|
|
815
|
+
takeSide,
|
|
586
816
|
}
|
|
587
817
|
|
|
588
818
|
/**
|
|
@@ -598,7 +828,7 @@ export function createOperations(deps) {
|
|
|
598
828
|
if (params === null || typeof params !== 'object') {
|
|
599
829
|
throw new GitError('参数必须是 JSON 对象', { code: 'invalid-params' })
|
|
600
830
|
}
|
|
601
|
-
const timeout = op === 'aiCommit'
|
|
831
|
+
const timeout = op === 'aiCommit' || op === 'aiConflict'
|
|
602
832
|
? timeouts.ai
|
|
603
833
|
: op === 'fetch' || op === 'pull' || op === 'push'
|
|
604
834
|
? timeouts.network
|