dsh-config-manager 0.1.76 → 0.1.78
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/lib/adapters/plugin-data-declared.d.ts +12 -0
- package/lib/adapters/plugin-data-declared.js +57 -0
- package/lib/adapters/plugin-data.d.ts +4 -6
- package/lib/adapters/plugin-data.js +8 -36
- package/lib/adapters/plugins.d.ts +16 -0
- package/lib/adapters/plugins.js +223 -16
- package/lib/client.d.ts +104 -9
- package/lib/client.js +1800 -718
- package/lib/core/analyzer.js +3 -1
- package/lib/core/backup.d.ts +9 -0
- package/lib/core/backup.js +16 -1
- package/lib/core/messages.d.ts +11 -0
- package/lib/core/messages.js +22 -0
- package/lib/core/plugin-cli.d.ts +26 -0
- package/lib/core/plugin-cli.js +62 -2
- package/lib/core/rollback-attribution.d.ts +81 -0
- package/lib/core/rollback-attribution.js +236 -0
- package/lib/core/rollback-merge.d.ts +22 -0
- package/lib/core/rollback-merge.js +203 -0
- package/lib/core/rollback-point.d.ts +114 -0
- package/lib/core/rollback-point.js +534 -17
- package/lib/core/types.d.ts +28 -0
- package/lib/index.d.ts +26 -3
- package/lib/index.js +129 -16
- package/lib/routes/recovery.js +11 -0
- package/lib/schema/section-registry.js +5 -5
- package/lib/schema/types.d.ts +14 -0
- package/lib/ui/badge-tone.d.ts +16 -0
- package/lib/ui/badge-tone.js +20 -0
- package/lib/ui/i18n.d.ts +4 -4
- package/lib/ui/i18n.js +8 -8
- package/lib/ui/rollback-view.d.ts +95 -0
- package/lib/ui/rollback-view.js +55 -0
- package/lib/ui/selection-model.d.ts +140 -1
- package/lib/ui/selection-model.js +241 -9
- package/package.json +1 -1
- package/src/adapters/plugin-data-declared.ts +60 -0
- package/src/adapters/plugin-data.ts +7 -33
- package/src/adapters/plugins.test.ts +162 -0
- package/src/adapters/plugins.ts +236 -17
- package/src/adapters/units.test.ts +26 -0
- package/src/client/chat/ChatEntryView.tsx +147 -46
- package/src/client/chat/chat-entry-view.test.ts +277 -1
- package/src/client/chat/chat-entry-view.ts +98 -4
- package/src/client/chat/chat-node-registration.ts +71 -0
- package/src/client/chat/chat-slots.ts +30 -12
- package/src/client/common/ContentPicker.tsx +581 -175
- package/src/client/common/host-primitives.ts +64 -1
- package/src/client/common/ui.tsx +17 -3
- package/src/client/config-manager.module.css +630 -397
- package/src/client/css-scale-guard.test.ts +41 -3
- package/src/client/css-token-guard.test.ts +10 -4
- package/src/client/export/ExportView.tsx +2 -0
- package/src/client/index.ts +15 -3
- package/src/client/locales.ts +54 -10
- package/src/client/recovery/RollbackCard.tsx +56 -2
- package/src/client/recovery/RollbackDiffPane.tsx +5 -0
- package/src/client/recovery/RollbackFileChanges.tsx +46 -2
- package/src/client/recovery/recovery-locales.ts +22 -0
- package/src/client/recovery/rollback-file-changes.test.ts +1 -1
- package/src/core/analyzer.ts +3 -1
- package/src/core/backup.ts +16 -1
- package/src/core/messages.ts +22 -0
- package/src/core/plugin-cli.fs.test.ts +65 -3
- package/src/core/plugin-cli.ts +55 -2
- package/src/core/rollback-attribution.test.ts +120 -0
- package/src/core/rollback-attribution.ts +267 -0
- package/src/core/rollback-merge.test.ts +133 -0
- package/src/core/rollback-merge.ts +213 -0
- package/src/core/rollback-point.test.ts +412 -0
- package/src/core/rollback-point.ts +560 -20
- package/src/core/types.ts +29 -0
- package/src/index.facade.test.ts +60 -0
- package/src/index.ts +133 -19
- package/src/routes/recovery.ts +11 -0
- package/src/schema/section-registry.ts +5 -5
- package/src/schema/types.ts +14 -0
- package/src/ui/badge-tone.test.ts +23 -0
- package/src/ui/badge-tone.ts +27 -0
- package/src/ui/i18n.ts +8 -8
- package/src/ui/rollback-view.test.ts +27 -0
- package/src/ui/rollback-view.ts +139 -0
- package/src/ui/selection-model.test.ts +240 -2
- package/src/ui/selection-model.ts +349 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 三方合并内核的单元测试(2026-10-17 跨对话安全回退)。
|
|
3
|
+
*
|
|
4
|
+
* 每条都用**真实文本**断言最终内容 —— 这个模块的产出会被直接写进用户的工作区文件,
|
|
5
|
+
* 「函数返回了 true」不算证据。
|
|
6
|
+
*/
|
|
7
|
+
import test from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
|
|
10
|
+
import { mergeThreeWay } from './rollback-merge.ts'
|
|
11
|
+
|
|
12
|
+
test('M-1 不同区域:撤销 ours 的改动、保留 theirs 的改动', () => {
|
|
13
|
+
const ours = ['function main() {', ' const a = 1', ' const b = 2', ' log(a + b)', '}'].join('\n') + '\n'
|
|
14
|
+
const base = ['function main() {', ' // A 加的注释', ' const b = 2', ' log(a + b)', '}'].join('\n') + '\n'
|
|
15
|
+
const theirs = ['function main() {', ' // A 加的注释', ' const b = 2', ' log(b)', '}'].join('\n') + '\n'
|
|
16
|
+
// ours 撤销了 A 的那行注释(base→ours 删除),theirs 改的是最后一行(base→theirs 修改)
|
|
17
|
+
const r = mergeThreeWay(ours, base, theirs)
|
|
18
|
+
assert.equal(r.ok, true, r.reason)
|
|
19
|
+
assert.equal(r.conflicts, 0)
|
|
20
|
+
const lines = r.text.split('\n')
|
|
21
|
+
assert.equal(lines.includes(' // A 加的注释'), false, 'A 的注释被撤销')
|
|
22
|
+
assert.equal(lines.includes(' log(b)'), true, 'B 的改动被保留')
|
|
23
|
+
assert.equal(lines.includes(' log(a + b)'), false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('M-2 同一行被两侧改过 ⇒ 报冲突、ok=false、文本回落 theirs', () => {
|
|
27
|
+
const ours = 'a\nB-ours\nc\n'
|
|
28
|
+
const base = 'a\nb\nc\n'
|
|
29
|
+
const theirs = 'a\nB-theirs\nc\n'
|
|
30
|
+
const r = mergeThreeWay(ours, base, theirs)
|
|
31
|
+
assert.equal(r.ok, false)
|
|
32
|
+
assert.equal(r.reason, 'conflict')
|
|
33
|
+
assert.equal(r.conflicts, 1)
|
|
34
|
+
assert.equal(r.text, theirs, '冲突时绝不给出半截合并结果')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('M-3 同一插入点写不同内容 ⇒ 冲突(不擅自决定顺序)', () => {
|
|
38
|
+
const base = 'a\nc\n'
|
|
39
|
+
const ours = 'a\nOURS\nc\n'
|
|
40
|
+
const theirs = 'a\nTHEIRS\nc\n'
|
|
41
|
+
const r = mergeThreeWay(ours, base, theirs)
|
|
42
|
+
assert.equal(r.ok, false)
|
|
43
|
+
assert.equal(r.reason, 'conflict')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('M-4 两侧做的是**同一件事** ⇒ 不算冲突', () => {
|
|
47
|
+
const base = 'a\nb\nc\n'
|
|
48
|
+
const ours = 'a\nX\nc\n'
|
|
49
|
+
const theirs = 'a\nX\nc\n'
|
|
50
|
+
const r = mergeThreeWay(ours, base, theirs)
|
|
51
|
+
assert.equal(r.ok, true)
|
|
52
|
+
assert.equal(r.text, 'a\nX\nc\n')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('M-5 不同插入点:两边都保留', () => {
|
|
56
|
+
const base = 'a\nb\nc\n'
|
|
57
|
+
const ours = 'a\nOURS\nb\nc\n'
|
|
58
|
+
const theirs = 'a\nb\nc\nTHEIRS\n'
|
|
59
|
+
const r = mergeThreeWay(ours, base, theirs)
|
|
60
|
+
assert.equal(r.ok, true, r.reason)
|
|
61
|
+
assert.equal(r.text, 'a\nOURS\nb\nc\nTHEIRS\n')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('M-6 ours 删除一块、theirs 改另一块 ⇒ 干净合并', () => {
|
|
65
|
+
const base = 'keep1\ndoomed\nkeep2\ntail\n'
|
|
66
|
+
const ours = 'keep1\nkeep2\ntail\n'
|
|
67
|
+
const theirs = 'keep1\ndoomed\nkeep2\nTAIL\n'
|
|
68
|
+
const r = mergeThreeWay(ours, base, theirs)
|
|
69
|
+
assert.equal(r.ok, true, r.reason)
|
|
70
|
+
assert.equal(r.text, 'keep1\nkeep2\nTAIL\n')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('M-7 二进制任一侧 ⇒ 不合并(reason=binary)', () => {
|
|
74
|
+
const base = 'a\u0000b\n'
|
|
75
|
+
const r = mergeThreeWay('x\n', base, 'y\n')
|
|
76
|
+
assert.equal(r.ok, false)
|
|
77
|
+
assert.equal(r.reason, 'binary')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('M-8 本会话没改过(ours==base)⇒ 保持对方当前内容(不是失败)', () => {
|
|
81
|
+
const base = 'a\nb\n'
|
|
82
|
+
const theirs = 'a\nB2\n'
|
|
83
|
+
const r = mergeThreeWay(base, base, theirs)
|
|
84
|
+
assert.equal(r.ok, true)
|
|
85
|
+
assert.equal(r.text, theirs)
|
|
86
|
+
assert.equal(r.revertedLines, 0)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('M-9 对方没改过(theirs==base)⇒ 回到 ours', () => {
|
|
90
|
+
const base = 'a\nb\n'
|
|
91
|
+
const ours = 'a\nO\n'
|
|
92
|
+
const r = mergeThreeWay(ours, base, base)
|
|
93
|
+
assert.equal(r.ok, true)
|
|
94
|
+
assert.equal(r.text, ours)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('M-10 尾换行跟随当前磁盘的形态', () => {
|
|
98
|
+
const base = 'a\nb\nc\n'
|
|
99
|
+
const ours = 'a\nO1\nc\n'
|
|
100
|
+
const theirsNoTail = 'a\nb\nC2'
|
|
101
|
+
const r = mergeThreeWay(ours, base, theirsNoTail)
|
|
102
|
+
assert.equal(r.ok, true, r.reason)
|
|
103
|
+
assert.equal(r.text.endsWith('\n'), false, '磁盘没有尾换行就不擅自补')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('M-11 依赖风险提示:被撤销的行里的标识符仍在结果中被引用(启发式)', () => {
|
|
107
|
+
const base = ['export function helperThing() {', ' return 1', '}', 'runSomething(helperThing)'].join('\n') + '\n'
|
|
108
|
+
// ours(回退目标)里没有 helperThing —— 撤销它会删掉该函数
|
|
109
|
+
const ours = 'runSomething(helperThing)\n'
|
|
110
|
+
// theirs 仍在调用它
|
|
111
|
+
const theirs = ['export function helperThing() {', ' return 1', '}', 'runSomething(helperThing)', 'moreCode()'].join('\n') + '\n'
|
|
112
|
+
const r = mergeThreeWay(ours, base, theirs)
|
|
113
|
+
assert.equal(r.ok, true, r.reason)
|
|
114
|
+
assert.ok(r.risks.includes('helperThing'), '必须提示这个可能仍被引用的标识符:' + JSON.stringify(r.risks))
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('M-12 一侧删除一块、另一侧正好在该块之后追加 ⇒ 不算冲突(真机 2026-10-17 形状)', () => {
|
|
118
|
+
const base = '# T\n\nA-line\n'
|
|
119
|
+
const ours = '# T\n'
|
|
120
|
+
const theirs = '# T\n\nA-line\nB-line'
|
|
121
|
+
const r = mergeThreeWay(ours, base, theirs)
|
|
122
|
+
assert.equal(r.ok, true, r.reason)
|
|
123
|
+
assert.equal(r.text, '# T\nB-line', '撤销 A 那一行、保留 B 追加的那一行')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('M-13 插入点落在被删区间**内部** ⇒ 仍然是冲突', () => {
|
|
127
|
+
const base = 'a\nMID1\nMID2\nc\n'
|
|
128
|
+
const ours = 'a\nc\n'
|
|
129
|
+
const theirs = 'a\nMID1\nB\nMID2\nc\n'
|
|
130
|
+
const r = mergeThreeWay(ours, base, theirs)
|
|
131
|
+
assert.equal(r.ok, false)
|
|
132
|
+
assert.equal(r.reason, 'conflict')
|
|
133
|
+
})
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 行级**三方合并**(base / ours / theirs)—— 回退时「撤销本会话的改动、保留别人的改动」的判定内核。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它(2026-10-17 跨对话安全回退):
|
|
5
|
+
* 回滚点的「工作区态」是整棵树的快照,回退若直接整文件覆盖,会把别的对话在留点之后
|
|
6
|
+
* 产生的修改一起抹掉。正确语义是:把 **base→ours**(本会话那次改动的反向)与
|
|
7
|
+
* **base→theirs**(别人后来的改动)同时应用到文件上 —— 即标准三方合并。
|
|
8
|
+
*
|
|
9
|
+
* 三方是谁(由调用方给定,本模块只管算):
|
|
10
|
+
* · `base` = 共同祖先:本会话改动之后、别人的改动之前的那个版本;
|
|
11
|
+
* · `ours` = 回退目标:回滚点里记录的内容(= 本会话改之前的版本);
|
|
12
|
+
* · `theirs` = 当前磁盘上的内容。
|
|
13
|
+
* 结果为 `ours + (theirs - base)`,即撤销本会话、保留别人。
|
|
14
|
+
*
|
|
15
|
+
* 四条硬规矩:
|
|
16
|
+
* ① **不确定就不合并**:任一方向的 diff 被规模预算降级(`degraded`)⇒ 直接拒绝(reason=`degraded`),
|
|
17
|
+
* 绝不拿一个「整块替换」的近似编辑脚本去算合并。
|
|
18
|
+
* ② **冲突绝不自动选边**:两侧改到同一处(含同一插入点)就报冲突,返回 theirs 原文 + `ok:false`,
|
|
19
|
+
* 调用方必须**不写文件**并把冲突交给用户。
|
|
20
|
+
* ③ **二进制不合并**(含 NUL 即二进制,与 git 同口径)。
|
|
21
|
+
* ④ **依赖风险只提示、不假装证明**:合并要删掉的行里的标识符若仍出现在结果中,
|
|
22
|
+
* 给出**启发式**风险提示(`risks`)—— 它不构成「删除不安全」的证明,也绝不因此声称合并正确。
|
|
23
|
+
*/
|
|
24
|
+
import { diffLines, looksBinary } from '../utils/line-diff.ts'
|
|
25
|
+
|
|
26
|
+
/** 合并结论(`ok:false` 时调用方必须**零写入**) */
|
|
27
|
+
export type ThreeWayMergeReason = 'clean' | 'conflict' | 'degraded' | 'binary'
|
|
28
|
+
|
|
29
|
+
export interface ThreeWayMergeResult {
|
|
30
|
+
/** true = 结果可直接写入;false = 必须放弃写入并如实报告 */
|
|
31
|
+
ok: boolean
|
|
32
|
+
/** 合并结果(`ok:false` 时是 theirs 原文 —— 方便调用方做「保持现状」的比较,绝不写入) */
|
|
33
|
+
text: string
|
|
34
|
+
reason: ThreeWayMergeReason
|
|
35
|
+
/** 冲突块数(>0 ⇒ ok=false) */
|
|
36
|
+
conflicts: number
|
|
37
|
+
/** 本次合并**撤销**的行数(base 有、ours 没有)与**保留**的对方新增行数(诊断用) */
|
|
38
|
+
revertedLines: number
|
|
39
|
+
/** 依赖风险提示(启发式;空数组 = 未发现明显风险,**不代表安全**) */
|
|
40
|
+
risks: string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 一处变更块:base 的 `[start, end)` 行被替换成 `lines`(空区间 = 纯插入) */
|
|
44
|
+
interface ChangeBlock {
|
|
45
|
+
start: number
|
|
46
|
+
end: number
|
|
47
|
+
lines: string[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 标识符取样用的最小长度(太短的词(i、id)同名词满天飞,只会制造噪声) */
|
|
51
|
+
const RISK_TOKEN_MIN = 4
|
|
52
|
+
|
|
53
|
+
/** 不参与依赖提示的通用词(语言关键字 / 极常见标识符) */
|
|
54
|
+
const RISK_STOPWORDS = new Set([
|
|
55
|
+
'const', 'let', 'var', 'function', 'return', 'import', 'export', 'from', 'class', 'interface', 'type',
|
|
56
|
+
'public', 'private', 'readonly', 'async', 'await', 'true', 'false', 'null', 'undefined', 'this', 'void',
|
|
57
|
+
'if', 'else', 'for', 'while', 'switch', 'case', 'break', 'continue', 'new', 'throw', 'try', 'catch',
|
|
58
|
+
'string', 'number', 'boolean', 'object', 'Array', 'Promise', 'Record', 'Partial', 'default',
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 一段文本相对 base 的**变更块**(按 base 的行区间表示)。
|
|
63
|
+
* 返回 null = 该方向的 diff 被降级(规模预算),调用方必须放弃合并。
|
|
64
|
+
*/
|
|
65
|
+
function changeBlocks(base: string, target: string): ChangeBlock[] | null {
|
|
66
|
+
// context=0:只要变更行,块边界自己算(省得剔上下文)
|
|
67
|
+
const diff = diffLines(base, target, { context: 0 })
|
|
68
|
+
if (diff.degraded) return null
|
|
69
|
+
const blocks: ChangeBlock[] = []
|
|
70
|
+
for (const hunk of diff.hunks) {
|
|
71
|
+
// 当前块与「已消耗到 base 的哪一行」
|
|
72
|
+
let block: ChangeBlock | null = null
|
|
73
|
+
let cursor = Math.max(0, hunk.oldStart - 1)
|
|
74
|
+
const flush = (): void => {
|
|
75
|
+
if (block !== null) blocks.push(block)
|
|
76
|
+
block = null
|
|
77
|
+
}
|
|
78
|
+
for (const line of hunk.lines) {
|
|
79
|
+
if (line.kind === 'context') {
|
|
80
|
+
flush()
|
|
81
|
+
if (line.oldNo !== null) cursor = line.oldNo
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if (line.kind === 'del') {
|
|
85
|
+
const at = line.oldNo === null ? cursor : line.oldNo - 1
|
|
86
|
+
if (block === null) block = { start: at, end: at, lines: [] }
|
|
87
|
+
block.end = at + 1
|
|
88
|
+
cursor = at + 1
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
// add:没有 del 时是纯插入(位置 = 当前游标)
|
|
92
|
+
if (block === null) block = { start: cursor, end: cursor, lines: [] }
|
|
93
|
+
block.lines.push(line.text)
|
|
94
|
+
}
|
|
95
|
+
flush()
|
|
96
|
+
}
|
|
97
|
+
return blocks
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 两个块在 base 上是否**互相触碰**(保守:含边界与纯插入点) */
|
|
101
|
+
function touches(a: ChangeBlock, b: ChangeBlock): boolean {
|
|
102
|
+
const aPoint = a.start === a.end
|
|
103
|
+
const bPoint = b.start === b.end
|
|
104
|
+
if (aPoint && bPoint) return a.start === b.start
|
|
105
|
+
// **纯插入点落在区间端点不算冲突**:「删掉 `[s,e)`」与「在 e 处插入」可以共存(先删、再在删除后
|
|
106
|
+
// 的位置插入)。真机 2026-10-17 的「撤销 A 的描述 + 保留 B 的描述」正是这种形状:A 删掉了尾部
|
|
107
|
+
// 一行,B 又在其后追加了一行 —— 旧判据把它们当冲突,于是回退变成「什么都不做」。
|
|
108
|
+
// 只有落在区间**内部**(严格介于 s 与 e 之间)的插入才是真正说不清的冲突。
|
|
109
|
+
if (aPoint) return a.start > b.start && a.start < b.end
|
|
110
|
+
if (bPoint) return b.start > a.start && b.start < a.end
|
|
111
|
+
return a.start < b.end && b.start < a.end
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 两侧的块是否「改成了同一件事」(内容逐行相同即可取其一,不算冲突) */
|
|
115
|
+
function sameChange(a: ChangeBlock, b: ChangeBlock): boolean {
|
|
116
|
+
if (a.lines.length !== b.lines.length) return false
|
|
117
|
+
for (let i = 0; i < a.lines.length; i += 1) if (a.lines[i] !== b.lines[i]) return false
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 从被撤销的行里取「可能仍被引用」的标识符(启发式,见模块头 ④) */
|
|
122
|
+
function riskTokens(lines: readonly string[]): string[] {
|
|
123
|
+
const seen = new Set<string>()
|
|
124
|
+
for (const line of lines) {
|
|
125
|
+
for (const token of line.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []) {
|
|
126
|
+
if (token.length < RISK_TOKEN_MIN) continue
|
|
127
|
+
if (RISK_STOPWORDS.has(token)) continue
|
|
128
|
+
seen.add(token)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return [...seen]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 三方合并(纯函数、无 I/O)。
|
|
136
|
+
*
|
|
137
|
+
* 语义:结果是 `ours` 上再应用 `(theirs - base)` —— 撤销「我们从 base 改到 ours 的那部分」,
|
|
138
|
+
* 同时保留「别人从 base 改到 theirs 的那部分」。
|
|
139
|
+
*/
|
|
140
|
+
export function mergeThreeWay(ours: string, base: string, theirs: string): ThreeWayMergeResult {
|
|
141
|
+
const empty = (reason: ThreeWayMergeReason, conflicts = 0, risks: string[] = []): ThreeWayMergeResult => ({
|
|
142
|
+
ok: false, text: theirs, reason, conflicts, revertedLines: 0, risks,
|
|
143
|
+
})
|
|
144
|
+
// 没有基线就退化成「谁的改动都不认识」—— 调用方负责在无 base 时根本不调用本函数
|
|
145
|
+
if (looksBinary(base) || looksBinary(ours) || looksBinary(theirs)) return empty('binary')
|
|
146
|
+
if (ours === base) {
|
|
147
|
+
// 本会话没改过 ⇒ 结论就是「保持别人现在的内容」(不是失败)
|
|
148
|
+
return { ok: true, text: theirs, reason: 'clean', conflicts: 0, revertedLines: 0, risks: [] }
|
|
149
|
+
}
|
|
150
|
+
if (theirs === base) {
|
|
151
|
+
// 别人没改过 ⇒ 直接回到 ours
|
|
152
|
+
return { ok: true, text: ours, reason: 'clean', conflicts: 0, revertedLines: 0, risks: [] }
|
|
153
|
+
}
|
|
154
|
+
if (ours === theirs) {
|
|
155
|
+
// 双方最终内容一致 ⇒ 谁都不用动
|
|
156
|
+
return { ok: true, text: theirs, reason: 'clean', conflicts: 0, revertedLines: 0, risks: [] }
|
|
157
|
+
}
|
|
158
|
+
const oursBlocks = changeBlocks(base, ours)
|
|
159
|
+
const theirsBlocks = changeBlocks(base, theirs)
|
|
160
|
+
if (oursBlocks === null || theirsBlocks === null) return empty('degraded')
|
|
161
|
+
|
|
162
|
+
// 冲突扫描:任一 ours 块与任一 theirs 块触碰且内容不同 ⇒ 冲突
|
|
163
|
+
let conflicts = 0
|
|
164
|
+
for (const a of oursBlocks) {
|
|
165
|
+
for (const b of theirsBlocks) {
|
|
166
|
+
if (!touches(a, b)) continue
|
|
167
|
+
if (sameChange(a, b)) continue
|
|
168
|
+
conflicts += 1
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (conflicts > 0) return empty('conflict', conflicts)
|
|
172
|
+
|
|
173
|
+
const baseLines = splitLinesOf(base)
|
|
174
|
+
const merged: string[] = []
|
|
175
|
+
const reverted: string[] = []
|
|
176
|
+
let cursor = 0
|
|
177
|
+
// 把两侧的块按 base 位置归并(位置相同的「同一改动」只应用一次)
|
|
178
|
+
const all = [
|
|
179
|
+
...oursBlocks.map((block) => ({ block, owner: 'ours' as const })),
|
|
180
|
+
...theirsBlocks.map((block) => ({ block, owner: 'theirs' as const })),
|
|
181
|
+
].sort((left, right) => left.block.start - right.block.start)
|
|
182
|
+
for (const item of all) {
|
|
183
|
+
const { block, owner } = item
|
|
184
|
+
if (block.start < cursor) {
|
|
185
|
+
// 已被同一位置的另一个块消费(只可能是「同一改动」)—— 跳过重复应用
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
for (let i = cursor; i < block.start && i < baseLines.length; i += 1) merged.push(baseLines[i] as string)
|
|
189
|
+
if (owner === 'ours') {
|
|
190
|
+
// 这一段就是「被撤销的本会话改动」:记下它删掉了哪些行,供依赖提示用
|
|
191
|
+
for (let i = block.start; i < block.end && i < baseLines.length; i += 1) reverted.push(baseLines[i] as string)
|
|
192
|
+
}
|
|
193
|
+
for (const line of block.lines) merged.push(line)
|
|
194
|
+
cursor = Math.max(cursor, block.end)
|
|
195
|
+
}
|
|
196
|
+
for (let i = cursor; i < baseLines.length; i += 1) merged.push(baseLines[i] as string)
|
|
197
|
+
// 尾换行以**当前磁盘**(theirs)为准:我们是在它的基础上撤销本会话的改动,不擅改它原本的收尾形态
|
|
198
|
+
const text = merged.join('\n') + (merged.length > 0 && theirs.endsWith('\n') ? '\n' : '')
|
|
199
|
+
|
|
200
|
+
// 依赖风险(启发式):被撤销的行里出现过、且合并结果里仍能读到的标识符
|
|
201
|
+
const tokens = riskTokens(reverted)
|
|
202
|
+
const risks = tokens.filter((token) => new RegExp('\\b' + token + '\\b').test(text)).slice(0, 8)
|
|
203
|
+
return { ok: true, text, reason: 'clean', conflicts: 0, revertedLines: reverted.length, risks }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 与 `splitLines` 同口径的本地折行(避免为了一个函数再引一层依赖) */
|
|
207
|
+
function splitLinesOf(text: string): string[] {
|
|
208
|
+
if (text === '') return []
|
|
209
|
+
const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
210
|
+
const lines = normalized.split('\n')
|
|
211
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
212
|
+
return lines
|
|
213
|
+
}
|