@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 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
- ## 38 秒介绍片
7
+ ## 45 秒介绍片
8
8
 
9
9
  https://github.com/user-attachments/assets/c57169b4-df00-450d-b4f0-7a2c64db03b6
10
10
 
11
- *全片界面为渲染示意,不含真实会话;[完整播放页](https://dsh-next-git.1017835747.workers.dev/) 另有下载版。*
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
- - 冲突只做展示(conflicted 分组 + 横幅提示),不提供 accept ours/theirs 之类的解决 UI。
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
- h('button', {
1357
- type: 'button',
1358
- className: 'gm-btn icon sm ghost gm-act',
1359
- title: staged ? tr('changes.unstage') : tr('changes.stage'),
1360
- onClick: function (event) {
1361
- event.stopPropagation()
1362
- if (staged) props.onUnstage(entry)
1363
- else props.onStage(entry)
1364
- },
1365
- }, h(Icon, { name: staged ? 'minus' : 'plus', size: 12 })),
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.mode === 'commit'
1743
- ? tr('diff.commit', { hash: state.selected && state.selected.hash ? state.selected.hash.slice(0, 8) : '' })
1744
- : diff.mode === 'staged' ? tr('diff.staged') : diff.mode === 'untracked' ? tr('diff.untracked') : tr('diff.worktree')
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
- diff.binary
1755
- ? h(EmptyState, { title: tr('diff.binary') })
1756
- : diff.hunks.length === 0
1757
- ? h(EmptyState, { title: tr('diff.noText') })
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caozeal/dsh-next-git",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "DSH Git 管理插件:分支切换、变更暂存、提交/推送/拉取、AI 提交信息、提交图与 diff 查看",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -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