dsh-config-manager 0.1.78 → 0.1.79
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/credentials.d.ts +6 -2
- package/lib/adapters/credentials.js +19 -3
- package/lib/adapters/file-collection.js +2 -1
- package/lib/adapters/mcp.d.ts +48 -2
- package/lib/adapters/mcp.js +265 -3
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-files.js +2 -1
- package/lib/adapters/plugins.js +105 -20
- package/lib/adapters/pnpm-workspace.d.ts +32 -0
- package/lib/adapters/pnpm-workspace.js +113 -2
- package/lib/adapters/prompts.d.ts +6 -2
- package/lib/adapters/prompts.js +17 -2
- package/lib/adapters/providers.d.ts +19 -2
- package/lib/adapters/providers.js +59 -3
- package/lib/adapters/settings.d.ts +6 -2
- package/lib/adapters/settings.js +13 -2
- package/lib/adapters/skills.d.ts +25 -1
- package/lib/adapters/skills.js +60 -0
- package/lib/adapters/ui.d.ts +6 -2
- package/lib/adapters/ui.js +13 -2
- package/lib/adapters/units.d.ts +18 -0
- package/lib/adapters/units.js +39 -0
- package/lib/adapters/workspaces.js +2 -1
- package/lib/client.d.ts +400 -112
- package/lib/client.js +2845 -1524
- package/lib/core/backup.js +20 -0
- package/lib/core/mcp-connector-store.d.ts +159 -0
- package/lib/core/mcp-connector-store.js +445 -0
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +20 -0
- package/lib/core/messages.js +44 -0
- package/lib/core/phase3-host.js +18 -1
- package/lib/core/plugin-cli.d.ts +22 -2
- package/lib/core/plugin-cli.js +22 -3
- package/lib/core/recovery-orchestrator.d.ts +3 -1
- package/lib/core/recovery-orchestrator.js +75 -16
- package/lib/core/rollback-activity.d.ts +89 -0
- package/lib/core/rollback-activity.js +350 -0
- package/lib/core/rollback-ledger.d.ts +0 -9
- package/lib/core/rollback-ledger.js +37 -0
- package/lib/core/rollback-merge.d.ts +22 -2
- package/lib/core/rollback-merge.js +63 -13
- package/lib/core/rollback-point.d.ts +93 -0
- package/lib/core/rollback-point.js +420 -101
- package/lib/core/rollback-undo-journal.d.ts +123 -0
- package/lib/core/rollback-undo-journal.js +517 -0
- package/lib/core/self-update.js +2 -4
- package/lib/core/text-fidelity.d.ts +89 -0
- package/lib/core/text-fidelity.js +176 -0
- package/lib/core/types.d.ts +12 -3
- package/lib/core/update-check.js +2 -4
- package/lib/core/validator.d.ts +9 -0
- package/lib/core/validator.js +12 -0
- package/lib/foreign/bundle.js +42 -3
- package/lib/foreign/claude-code.js +358 -26
- package/lib/foreign/claude-sessions.d.ts +51 -8
- package/lib/foreign/claude-sessions.js +111 -13
- package/lib/foreign/read-claude-code.d.ts +33 -4
- package/lib/foreign/read-claude-code.js +446 -65
- package/lib/foreign/read-claude-plugins.d.ts +36 -0
- package/lib/foreign/read-claude-plugins.js +403 -0
- package/lib/foreign/registry.js +25 -2
- package/lib/foreign/truth-table.js +6 -4
- package/lib/foreign/types.d.ts +170 -2
- package/lib/index.d.ts +14 -5
- package/lib/index.js +60 -16
- package/lib/market/git-file-writer.js +7 -1
- package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
- package/lib/profiles/dsh-profile-launcher.js +58 -1
- package/lib/profiles/dsh-profile-shared.d.ts +8 -2
- package/lib/profiles/dsh-profile-shared.js +8 -2
- package/lib/routes/foreign.js +8 -1
- package/lib/routes/index.d.ts +1 -1
- package/lib/routes/kit.d.ts +2 -2
- package/lib/routes/sync.js +173 -9
- package/lib/schema/manifest.d.ts +6 -1
- package/lib/schema/manifest.js +55 -0
- package/lib/schema/types.d.ts +47 -2
- package/lib/sync/autosync-config.d.ts +73 -0
- package/lib/sync/autosync-config.js +89 -0
- package/lib/sync/autosync-scheduler.d.ts +80 -6
- package/lib/sync/autosync-scheduler.js +263 -40
- package/lib/sync/gist/gist-transport.d.ts +15 -1
- package/lib/sync/gist/gist-transport.js +66 -5
- package/lib/sync/gist/messages.d.ts +3 -0
- package/lib/sync/gist/messages.js +7 -0
- package/lib/sync/git/git-transport.d.ts +42 -3
- package/lib/sync/git/git-transport.js +146 -3
- package/lib/sync/merge.d.ts +47 -14
- package/lib/sync/merge.js +242 -222
- package/lib/sync/messages.d.ts +8 -0
- package/lib/sync/messages.js +19 -0
- package/lib/sync/risk.d.ts +19 -2
- package/lib/sync/risk.js +10 -3
- package/lib/sync/s3/messages.d.ts +6 -0
- package/lib/sync/s3/messages.js +13 -0
- package/lib/sync/s3/s3-transport.d.ts +9 -1
- package/lib/sync/s3/s3-transport.js +78 -6
- package/lib/sync/sync-engine.d.ts +193 -4
- package/lib/sync/sync-engine.js +450 -37
- package/lib/sync/sync-history.d.ts +11 -0
- package/lib/sync/sync-history.js +20 -0
- package/lib/sync/sync-state.d.ts +43 -0
- package/lib/sync/sync-state.js +44 -0
- package/lib/sync/sync-watcher.d.ts +76 -0
- package/lib/sync/sync-watcher.js +202 -0
- package/lib/sync/text-merge.d.ts +85 -0
- package/lib/sync/text-merge.js +513 -0
- package/lib/sync/transport.d.ts +48 -0
- package/lib/sync/transport.js +12 -0
- package/lib/sync/watch-scope.d.ts +38 -0
- package/lib/sync/watch-scope.js +95 -0
- package/lib/sync/webdav/webdav-transport.d.ts +16 -2
- package/lib/sync/webdav/webdav-transport.js +128 -11
- package/lib/ui/foreign-view.js +10 -0
- package/lib/ui/i18n.d.ts +8 -0
- package/lib/ui/i18n.js +20 -0
- package/lib/ui/rollback-view.d.ts +12 -0
- package/lib/ui/rollback-view.js +2 -0
- package/lib/ui/sync-settings-view.d.ts +47 -0
- package/lib/ui/sync-settings-view.js +24 -0
- package/lib/ui/types.d.ts +21 -0
- package/lib/utils/env-lock.d.ts +1 -0
- package/lib/utils/env-lock.js +7 -4
- package/package.json +6 -6
- package/src/adapters/credentials.ts +22 -4
- package/src/adapters/file-collection.ts +2 -1
- package/src/adapters/mcp.test.ts +235 -1
- package/src/adapters/mcp.ts +276 -5
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-files.ts +2 -1
- package/src/adapters/plugins.test.ts +176 -2
- package/src/adapters/plugins.ts +107 -21
- package/src/adapters/pnpm-workspace.test.ts +89 -1
- package/src/adapters/pnpm-workspace.ts +127 -2
- package/src/adapters/prompts.ts +20 -3
- package/src/adapters/providers.ts +62 -4
- package/src/adapters/settings.ts +16 -3
- package/src/adapters/skills.test.ts +141 -0
- package/src/adapters/skills.ts +58 -2
- package/src/adapters/ui.ts +16 -3
- package/src/adapters/units-entries.test.ts +190 -0
- package/src/adapters/units.ts +50 -0
- package/src/adapters/workspaces.ts +2 -1
- package/src/client/about/PluginUpdateDialog.tsx +4 -2
- package/src/client/about/ReleaseNotesDialog.tsx +4 -2
- package/src/client/chat/RollbackReviewDialog.tsx +3 -2
- package/src/client/common/Skeleton.tsx +32 -0
- package/src/client/common/routes.ts +9 -0
- package/src/client/config-manager.module.css +321 -11
- package/src/client/home/HomePanel.tsx +14 -6
- package/src/client/import/ForeignImportView.tsx +3 -1
- package/src/client/import/import-wizard-steps.tsx +2 -1
- package/src/client/library/ArtifactRow.tsx +12 -2
- package/src/client/library/LibraryActions.tsx +6 -3
- package/src/client/library/LibraryPanel.tsx +21 -3
- package/src/client/library/RestorePlanView.tsx +4 -2
- package/src/client/locales.ts +3 -0
- package/src/client/recovery/RecoveryPanel.tsx +54 -7
- package/src/client/recovery/RollbackCard.tsx +7 -2
- package/src/client/recovery/RollbackDiffPane.tsx +3 -2
- package/src/client/recovery/recovery-locales.ts +20 -2
- package/src/client/recovery/recovery-view.test.ts +39 -1
- package/src/client/recovery/recovery-view.ts +23 -1
- package/src/client/run-store.test.ts +1 -1
- package/src/client/run-store.ts +4 -0
- package/src/client/sync/AutosyncCard.tsx +49 -6
- package/src/client/sync/MergeModal.tsx +219 -0
- package/src/client/sync/MergePreviewView.tsx +306 -0
- package/src/client/sync/SyncActivityBar.tsx +31 -0
- package/src/client/sync/SyncConfirmView.tsx +2 -1
- package/src/client/sync/SyncPanel.tsx +130 -87
- package/src/client/sync/SyncProbeRow.tsx +162 -0
- package/src/client/sync/SyncSettingsView.test.ts +27 -2
- package/src/client/sync/WorkDirCard.tsx +44 -6
- package/src/client/sync/history-model.ts +4 -1
- package/src/client/sync/merge-view.test.ts +234 -0
- package/src/client/sync/merge-view.ts +371 -0
- package/src/client/sync/probe-view.test.ts +90 -0
- package/src/client/sync/probe-view.ts +63 -0
- package/src/client/sync/remote-device-view.test.ts +35 -0
- package/src/client/sync/remote-device-view.ts +25 -0
- package/src/client/sync/sync-api.ts +212 -2
- package/src/client/sync/sync-entry.test.ts +78 -0
- package/src/client/sync/sync-entry.ts +53 -0
- package/src/client/sync/sync-locales.ts +234 -2
- package/src/client/sync/sync-view.test.ts +18 -1
- package/src/client/sync/sync-view.ts +20 -0
- package/src/core/backup.test.ts +41 -0
- package/src/core/backup.ts +20 -0
- package/src/core/mcp-connector-store.test.ts +265 -0
- package/src/core/mcp-connector-store.ts +516 -0
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +46 -0
- package/src/core/phase3-host.ts +18 -1
- package/src/core/phase3-production-integration.test.ts +4 -0
- package/src/core/plugin-cli.test.ts +26 -0
- package/src/core/plugin-cli.ts +30 -4
- package/src/core/recovery-orchestrator.test.ts +104 -5
- package/src/core/recovery-orchestrator.ts +74 -17
- package/src/core/rollback-activity.test.ts +311 -0
- package/src/core/rollback-activity.ts +367 -0
- package/src/core/rollback-cross-session.test.ts +694 -0
- package/src/core/rollback-interrupted.test.ts +137 -0
- package/src/core/rollback-ledger.ts +32 -0
- package/src/core/rollback-merge.test.ts +130 -0
- package/src/core/rollback-merge.ts +78 -14
- package/src/core/rollback-point.ts +468 -91
- package/src/core/rollback-undo-journal.test.ts +508 -0
- package/src/core/rollback-undo-journal.ts +571 -0
- package/src/core/self-update.ts +2 -5
- package/src/core/text-fidelity.test.ts +189 -0
- package/src/core/text-fidelity.ts +197 -0
- package/src/core/types.ts +11 -3
- package/src/core/update-check.ts +2 -5
- package/src/core/validator.ts +14 -0
- package/src/foreign/bundle-origin.test.ts +265 -0
- package/src/foreign/bundle.ts +45 -5
- package/src/foreign/claude-code.test.ts +268 -0
- package/src/foreign/claude-code.ts +406 -25
- package/src/foreign/claude-plugins.test.ts +234 -0
- package/src/foreign/claude-sessions.test.ts +141 -0
- package/src/foreign/claude-sessions.ts +179 -23
- package/src/foreign/file-budget.test.ts +4 -1
- package/src/foreign/read-claude-code.test.ts +320 -0
- package/src/foreign/read-claude-code.ts +490 -71
- package/src/foreign/read-claude-plugins.ts +465 -0
- package/src/foreign/registry.ts +25 -2
- package/src/foreign/session-ir.test.ts +65 -0
- package/src/foreign/truth-table.test.ts +81 -0
- package/src/foreign/truth-table.ts +6 -3
- package/src/foreign/types.ts +162 -2
- package/src/index.facade.test.ts +26 -4
- package/src/index.ts +76 -19
- package/src/market/git-file-writer.test.ts +4 -0
- package/src/market/git-file-writer.ts +7 -1
- package/src/profiles/dsh-profile-launcher.test.ts +68 -3
- package/src/profiles/dsh-profile-launcher.ts +56 -1
- package/src/profiles/dsh-profile-shared.ts +8 -2
- package/src/routes/foreign.ts +8 -1
- package/src/routes/index.ts +1 -1
- package/src/routes/kit.ts +2 -2
- package/src/routes/sync.ts +176 -9
- package/src/schema/manifest-schema.test.ts +87 -2
- package/src/schema/manifest.ts +57 -1
- package/src/schema/types.ts +45 -2
- package/src/sync/autosync-config.test.ts +73 -1
- package/src/sync/autosync-config.ts +124 -0
- package/src/sync/autosync-risk-tier.test.ts +38 -2
- package/src/sync/autosync-scheduler.test.ts +264 -12
- package/src/sync/autosync-scheduler.ts +296 -44
- package/src/sync/gist/gist-conditional-write.test.ts +223 -0
- package/src/sync/gist/gist-transport.ts +73 -7
- package/src/sync/gist/messages.ts +7 -0
- package/src/sync/git/git-probe.test.ts +80 -0
- package/src/sync/git/git-transport.test.ts +185 -0
- package/src/sync/git/git-transport.ts +178 -5
- package/src/sync/merge.test.ts +188 -55
- package/src/sync/merge.ts +282 -224
- package/src/sync/messages.ts +19 -0
- package/src/sync/risk.ts +29 -5
- package/src/sync/s3/messages.ts +13 -0
- package/src/sync/s3/s3-conditional-write.test.ts +330 -0
- package/src/sync/s3/s3-transport.ts +87 -9
- package/src/sync/sync-device-identity.test.ts +267 -0
- package/src/sync/sync-engine.test.ts +379 -0
- package/src/sync/sync-engine.ts +535 -36
- package/src/sync/sync-history.test.ts +16 -1
- package/src/sync/sync-history.ts +26 -0
- package/src/sync/sync-state.test.ts +64 -0
- package/src/sync/sync-state.ts +79 -0
- package/src/sync/sync-watcher.test.ts +197 -0
- package/src/sync/sync-watcher.ts +246 -0
- package/src/sync/text-merge.test.ts +358 -0
- package/src/sync/text-merge.ts +554 -0
- package/src/sync/transport.ts +57 -0
- package/src/sync/watch-scope.test.ts +122 -0
- package/src/sync/watch-scope.ts +120 -0
- package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
- package/src/sync/webdav/webdav-transport.ts +137 -14
- package/src/ui/foreign-view.test.ts +34 -0
- package/src/ui/foreign-view.ts +10 -0
- package/src/ui/i18n.ts +20 -0
- package/src/ui/rollback-view.ts +14 -0
- package/src/ui/sync-settings-view.test.ts +28 -1
- package/src/ui/sync-settings-view.ts +43 -0
- package/src/ui/types.ts +22 -0
- package/src/utils/env-lock.ts +8 -5
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-sync-flow:行级文本三方合并 + 递归 JSON 结构化合并(零依赖纯函数)。
|
|
3
|
+
*
|
|
4
|
+
* 职责边界(本文件只做纯计算:不读配置、不落盘、不打日志):
|
|
5
|
+
* - looksLikeText / tryParseJson:内容形态判定(文本 / JSON);
|
|
6
|
+
* - mergeJsonValues:JSON 值级递归三方合并(对象按 key 递归;数组绝不按索引合并);
|
|
7
|
+
* - mergeTextThreeWay:文本级三方合并(三段都是合法 JSON 时优先走结构化合并);
|
|
8
|
+
* - mergeBytesThreeWay:字节级入口,非文本 / 超限 / 缺祖先一律如实拒绝(绝不猜)。
|
|
9
|
+
*
|
|
10
|
+
* 安全口径(改这里前先读):
|
|
11
|
+
* - 冲突优先于猜测:语义不明的合并(数组双侧改动、同一区域双侧改动、类型互换)一律报冲突,
|
|
12
|
+
* 绝不静默丢弃任一侧的键 / 插件配置 / 用户数据。
|
|
13
|
+
* - 行对齐只走 LCS(base→local、base→remote),同一行内容重复时也不会错位;单侧变更区间
|
|
14
|
+
* 超过 DP 上限时退化为「整段替换」——宁可粗粒度报冲突,也不做定位猜测。
|
|
15
|
+
* - 文本比较按「去行尾」后的内容进行:CRLF/LF 混用不得误报冲突;输出保留输入的行尾风格。
|
|
16
|
+
* - 缺共同祖先(ancestor === undefined)→ ok:false:由上层按整文件冲突处理,本模块不猜。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 文本判定与文本合并的缺省字节上限:1 MiB。超限一律按「太大不合并」处理。 */
|
|
20
|
+
export const TEXT_MERGE_MAX_BYTES = 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
/** 单条冲突。local/remote/ancestor 是原始值:JSON 合并给原始 JSON 值,文本合并给字符串片段。 */
|
|
23
|
+
export interface TextMergeConflict {
|
|
24
|
+
/** 冲突位置:JSON 合并是点号路径('a.b.c',根为 '');文本合并是 'line 12-14'(或单点 'line 7')。 */
|
|
25
|
+
path: string;
|
|
26
|
+
reason: 'overlap' | 'array' | 'format' | 'binary' | 'too-large';
|
|
27
|
+
local?: unknown;
|
|
28
|
+
remote?: unknown;
|
|
29
|
+
ancestor?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** JSON 值级合并结果;ok 为 false 时 merged 缺省(绝不把半成品当结果)。 */
|
|
33
|
+
export interface JsonMergeResult {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
merged?: unknown;
|
|
36
|
+
conflicts: TextMergeConflict[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 合并内容形态:合法 JSON 文本 / 行级文本 / 二进制 / 超限。 */
|
|
40
|
+
export type TextMergeKind = 'text' | 'json' | 'binary' | 'too-large';
|
|
41
|
+
|
|
42
|
+
/** 文本(或字节)三方合并结果。 */
|
|
43
|
+
export interface TextMergeResult {
|
|
44
|
+
ok: boolean;
|
|
45
|
+
merged?: string;
|
|
46
|
+
conflicts: TextMergeConflict[];
|
|
47
|
+
kind: TextMergeKind;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 字节三方合并结果。 */
|
|
51
|
+
export interface TextBytesMergeResult {
|
|
52
|
+
ok: boolean;
|
|
53
|
+
merged?: Uint8Array;
|
|
54
|
+
conflicts: TextMergeConflict[];
|
|
55
|
+
kind: TextMergeKind;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// 形态判定
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 是否像文本:含 NUL 字节 / 非法 UTF-8 / 超过 maxBytes(缺省 1 MiB)→ false。
|
|
64
|
+
* 只做形态判定,不做解码;调用方拿到 true 后可用 UTF-8 解码。
|
|
65
|
+
*/
|
|
66
|
+
export function looksLikeText(bytes: Uint8Array, maxBytes: number = TEXT_MERGE_MAX_BYTES): boolean {
|
|
67
|
+
if (bytes.length > maxBytes) return false;
|
|
68
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
69
|
+
if (bytes[i] === 0) return false;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 严格 JSON 解析(不做 JSON5 / 注释 / 尾逗号宽容):解析失败返回 ok:false。 */
|
|
80
|
+
export function tryParseJson(text: string): { ok: true; value: unknown } | { ok: false } {
|
|
81
|
+
try {
|
|
82
|
+
return { ok: true, value: JSON.parse(text) as unknown };
|
|
83
|
+
} catch {
|
|
84
|
+
return { ok: false };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 字节形态判定(超限优先于二进制:两者都不可合并,但超限更能解释「为什么没合并」)。 */
|
|
89
|
+
function classifyBytes(bytes: Uint8Array): TextMergeKind {
|
|
90
|
+
if (bytes.length > TEXT_MERGE_MAX_BYTES) return 'too-large';
|
|
91
|
+
if (!looksLikeText(bytes)) return 'binary';
|
|
92
|
+
return 'text';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 取最「坏」的形态:too-large > binary > text。 */
|
|
96
|
+
function worstKind(kinds: TextMergeKind[]): TextMergeKind {
|
|
97
|
+
if (kinds.includes('too-large')) return 'too-large';
|
|
98
|
+
if (kinds.includes('binary')) return 'binary';
|
|
99
|
+
return 'text';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function decodeUtf8(bytes: Uint8Array): string {
|
|
103
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// JSON 结构化三方合并
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
111
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hasOwn(obj: Record<string, unknown>, key: string): boolean {
|
|
115
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 结构相等(对象按 key 集合比较,与键顺序无关)——三方合并的「未改动」判据。 */
|
|
119
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
120
|
+
if (a === b) return true;
|
|
121
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
122
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
123
|
+
if (a.length !== b.length) return false;
|
|
124
|
+
for (let i = 0; i < a.length; i++) {
|
|
125
|
+
if (!deepEqual(a[i], b[i])) return false;
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
130
|
+
const ka = Object.keys(a);
|
|
131
|
+
const kb = Object.keys(b);
|
|
132
|
+
if (ka.length !== kb.length) return false;
|
|
133
|
+
for (const key of ka) {
|
|
134
|
+
if (!hasOwn(b, key)) return false;
|
|
135
|
+
if (!deepEqual(a[key], b[key])) return false;
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 点号路径拼接:根为 '',子键为 'a' / 'a.b'。 */
|
|
143
|
+
function childPath(parent: string, key: string): string {
|
|
144
|
+
return parent === '' ? key : parent + '.' + key;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 三方合并 JSON 值(递归,任意深度)。
|
|
149
|
+
* - 双侧相同 → 该值(收敛);
|
|
150
|
+
* - 仅一侧相对祖先改动 → 采纳该侧;
|
|
151
|
+
* - 对象(祖先缺省或同为对象)→ 按键递归,**缺失键 = 删除语义**:
|
|
152
|
+
* 一侧删除 + 另一侧未改 → 删除胜出;一侧删除 + 另一侧改了 → 冲突;
|
|
153
|
+
* 单侧新增(祖先没有)→ 采纳;双侧都新增且不同 → 冲突;
|
|
154
|
+
* - 数组绝不按索引合并:双侧都改且不 deepEqual → 冲突(reason='array');
|
|
155
|
+
* - 其余(标量双向改动、类型互换)→ 冲突(reason='overlap')。
|
|
156
|
+
*/
|
|
157
|
+
export function mergeJsonValues(ancestor: unknown, local: unknown, remote: unknown, path = ''): JsonMergeResult {
|
|
158
|
+
const conflicts: TextMergeConflict[] = [];
|
|
159
|
+
const merged = resolveJsonValue(ancestor, local, remote, path, conflicts);
|
|
160
|
+
if (conflicts.length > 0) return { ok: false, conflicts };
|
|
161
|
+
return { ok: true, merged, conflicts: [] };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function resolveJsonValue(
|
|
165
|
+
ancestor: unknown,
|
|
166
|
+
local: unknown,
|
|
167
|
+
remote: unknown,
|
|
168
|
+
path: string,
|
|
169
|
+
conflicts: TextMergeConflict[],
|
|
170
|
+
): unknown {
|
|
171
|
+
if (deepEqual(local, remote)) return local;
|
|
172
|
+
if (deepEqual(local, ancestor)) return remote;
|
|
173
|
+
if (deepEqual(remote, ancestor)) return local;
|
|
174
|
+
// 双侧都改且不等:只有「两侧都是对象且祖先缺省或同为对象」才允许继续递归。
|
|
175
|
+
if (isPlainObject(local) && isPlainObject(remote) && (ancestor === undefined || isPlainObject(ancestor))) {
|
|
176
|
+
return mergeJsonObjects(isPlainObject(ancestor) ? ancestor : undefined, local, remote, path, conflicts);
|
|
177
|
+
}
|
|
178
|
+
if (Array.isArray(local) && Array.isArray(remote)) {
|
|
179
|
+
// 数组语义不明:绝不按索引乱合,交给用户裁决。
|
|
180
|
+
conflicts.push({ path, reason: 'array', local, remote, ancestor });
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
conflicts.push({ path, reason: 'overlap', local, remote, ancestor });
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function mergeJsonObjects(
|
|
188
|
+
ancestor: Record<string, unknown> | undefined,
|
|
189
|
+
local: Record<string, unknown>,
|
|
190
|
+
remote: Record<string, unknown>,
|
|
191
|
+
path: string,
|
|
192
|
+
conflicts: TextMergeConflict[],
|
|
193
|
+
): Record<string, unknown> {
|
|
194
|
+
const out: Record<string, unknown> = {};
|
|
195
|
+
const keys = new Set<string>([...Object.keys(ancestor ?? {}), ...Object.keys(local), ...Object.keys(remote)]);
|
|
196
|
+
for (const key of keys) {
|
|
197
|
+
const here = childPath(path, key);
|
|
198
|
+
const hasLocal = hasOwn(local, key);
|
|
199
|
+
const hasRemote = hasOwn(remote, key);
|
|
200
|
+
const hasAncestor = ancestor !== undefined && hasOwn(ancestor, key);
|
|
201
|
+
const ancestorValue = hasAncestor ? ancestor![key] : undefined;
|
|
202
|
+
if (hasLocal && hasRemote) {
|
|
203
|
+
out[key] = resolveJsonValue(ancestorValue, local[key], remote[key], here, conflicts);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (!hasLocal && !hasRemote) continue; // 双侧都删:此处无键,无需报告
|
|
207
|
+
const localSide = hasLocal;
|
|
208
|
+
const value = localSide ? local[key] : remote[key];
|
|
209
|
+
if (!hasAncestor) {
|
|
210
|
+
// 单侧新增:直接采纳(双侧都新增会走上面的分支)。
|
|
211
|
+
out[key] = value;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (deepEqual(value, ancestorValue)) continue; // 另一侧删除、这一侧没改 → 删除胜出
|
|
215
|
+
conflicts.push({
|
|
216
|
+
path: here,
|
|
217
|
+
reason: 'overlap',
|
|
218
|
+
local: localSide ? local[key] : undefined,
|
|
219
|
+
remote: localSide ? undefined : remote[key],
|
|
220
|
+
ancestor: ancestorValue,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// 行级文本三方合并
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
/** 一行:内容(不含行尾)+ 行尾('\n' | '\r\n' | '')。 */
|
|
231
|
+
interface LineEntry {
|
|
232
|
+
text: string;
|
|
233
|
+
eol: string;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 拆行:只把 '\n' 当行尾(CRLF 记为 '\r\n' 行尾);末尾没有换行时最后一行 eol 为空串。 */
|
|
237
|
+
function splitLines(text: string): LineEntry[] {
|
|
238
|
+
if (text === '') return [];
|
|
239
|
+
const out: LineEntry[] = [];
|
|
240
|
+
let start = 0;
|
|
241
|
+
for (let i = 0; i < text.length; i++) {
|
|
242
|
+
if (text.charCodeAt(i) !== 10) continue; // '\n'
|
|
243
|
+
const hasCr = i > start && text.charCodeAt(i - 1) === 13; // '\r'
|
|
244
|
+
out.push({ text: text.slice(start, hasCr ? i - 1 : i), eol: hasCr ? '\r\n' : '\n' });
|
|
245
|
+
start = i + 1;
|
|
246
|
+
}
|
|
247
|
+
if (start < text.length) out.push({ text: text.slice(start), eol: '' });
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 输出行尾风格:三种输入里只有 CRLF → CRLF,只有 LF → LF;
|
|
253
|
+
* 混用时优先跟随占多数的祖先,其次本地,最后回落 LF。
|
|
254
|
+
* (仅用于补齐「本来没有行尾、但合并后不在末行」的行——绝不改写原有行尾。)
|
|
255
|
+
*/
|
|
256
|
+
function detectEol(ancestorText: string, localText: string, remoteText: string): string {
|
|
257
|
+
const count = (t: string): { crlf: number; lf: number } => {
|
|
258
|
+
let crlf = 0;
|
|
259
|
+
let lf = 0;
|
|
260
|
+
for (let i = 0; i < t.length; i++) {
|
|
261
|
+
if (t.charCodeAt(i) !== 10) continue;
|
|
262
|
+
if (i > 0 && t.charCodeAt(i - 1) === 13) crlf++;
|
|
263
|
+
else lf++;
|
|
264
|
+
}
|
|
265
|
+
return { crlf, lf };
|
|
266
|
+
};
|
|
267
|
+
const a = count(ancestorText);
|
|
268
|
+
const l = count(localText);
|
|
269
|
+
const r = count(remoteText);
|
|
270
|
+
const crlf = a.crlf + l.crlf + r.crlf;
|
|
271
|
+
const lf = a.lf + l.lf + r.lf;
|
|
272
|
+
if (crlf > 0 && lf === 0) return '\r\n';
|
|
273
|
+
if (lf > 0 && crlf === 0) return '\n';
|
|
274
|
+
if (crlf === 0) return '\n';
|
|
275
|
+
if (a.crlf !== a.lf) return a.crlf > a.lf ? '\r\n' : '\n';
|
|
276
|
+
if (l.crlf !== l.lf) return l.crlf > l.lf ? '\r\n' : '\n';
|
|
277
|
+
return '\n';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 合并后重建文本:非末行的空行尾用 preferredEol 补齐(否则会把两行粘成一行)。 */
|
|
281
|
+
function rebuildLines(entries: LineEntry[], preferredEol: string): string {
|
|
282
|
+
let out = '';
|
|
283
|
+
for (let i = 0; i < entries.length; i++) {
|
|
284
|
+
const e = entries[i]!;
|
|
285
|
+
out += e.text;
|
|
286
|
+
if (i < entries.length - 1) out += e.eol === '' ? preferredEol : e.eol;
|
|
287
|
+
else out += e.eol;
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** 单侧变更:base 上 [baseStart, baseEnd) 被 lines 替换(lines 为空 = 纯删除)。 */
|
|
293
|
+
interface ChangeHunk {
|
|
294
|
+
baseStart: number;
|
|
295
|
+
baseEnd: number;
|
|
296
|
+
lines: LineEntry[];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** 0 = 相同,-1 = 删除,1 = 插入。 */
|
|
300
|
+
type DiffOp = 0 | -1 | 1;
|
|
301
|
+
|
|
302
|
+
/** 变更区间过大时的 DP 上限(n*m);超过则整段替换,绝不做定位猜测。 */
|
|
303
|
+
const DIFF_DP_LIMIT = 4_000_000;
|
|
304
|
+
|
|
305
|
+
/** 行级 diff(裁剪公共前后缀 + LCS 回溯);只比较内容,不含行尾。 */
|
|
306
|
+
function diffOps(a: string[], b: string[]): DiffOp[] {
|
|
307
|
+
let start = 0;
|
|
308
|
+
const maxPrefix = Math.min(a.length, b.length);
|
|
309
|
+
while (start < maxPrefix && a[start] === b[start]) start++;
|
|
310
|
+
let endA = a.length;
|
|
311
|
+
let endB = b.length;
|
|
312
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
313
|
+
endA--;
|
|
314
|
+
endB--;
|
|
315
|
+
}
|
|
316
|
+
const midA = a.slice(start, endA);
|
|
317
|
+
const midB = b.slice(start, endB);
|
|
318
|
+
const n = midA.length;
|
|
319
|
+
const m = midB.length;
|
|
320
|
+
const ops: DiffOp[] = [];
|
|
321
|
+
for (let i = 0; i < start; i++) ops.push(0);
|
|
322
|
+
if (n === 0) {
|
|
323
|
+
for (let j = 0; j < m; j++) ops.push(1);
|
|
324
|
+
} else if (m === 0) {
|
|
325
|
+
for (let i = 0; i < n; i++) ops.push(-1);
|
|
326
|
+
} else if (n * m <= DIFF_DP_LIMIT) {
|
|
327
|
+
const width = m + 1;
|
|
328
|
+
const lcs = new Int32Array((n + 1) * width);
|
|
329
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
330
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
331
|
+
const diag = lcs[(i + 1) * width + j + 1]!;
|
|
332
|
+
const down = lcs[(i + 1) * width + j]!;
|
|
333
|
+
const right = lcs[i * width + j + 1]!;
|
|
334
|
+
lcs[i * width + j] = midA[i] === midB[j] ? diag + 1 : Math.max(down, right);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
let i = 0;
|
|
338
|
+
let j = 0;
|
|
339
|
+
while (i < n && j < m) {
|
|
340
|
+
if (midA[i] === midB[j]) {
|
|
341
|
+
ops.push(0);
|
|
342
|
+
i++;
|
|
343
|
+
j++;
|
|
344
|
+
} else if (lcs[(i + 1) * width + j]! >= lcs[i * width + j + 1]!) {
|
|
345
|
+
ops.push(-1);
|
|
346
|
+
i++;
|
|
347
|
+
} else {
|
|
348
|
+
ops.push(1);
|
|
349
|
+
j++;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
while (i < n) {
|
|
353
|
+
ops.push(-1);
|
|
354
|
+
i++;
|
|
355
|
+
}
|
|
356
|
+
while (j < m) {
|
|
357
|
+
ops.push(1);
|
|
358
|
+
j++;
|
|
359
|
+
}
|
|
360
|
+
} else {
|
|
361
|
+
for (let i = 0; i < n; i++) ops.push(-1);
|
|
362
|
+
for (let j = 0; j < m; j++) ops.push(1);
|
|
363
|
+
}
|
|
364
|
+
for (let k = 0; k < a.length - endA; k++) ops.push(0);
|
|
365
|
+
return ops;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 把单侧 diff 归并成变更区间(连续删除/插入合成一个 hunk)。 */
|
|
369
|
+
function changeHunks(base: LineEntry[], side: LineEntry[]): ChangeHunk[] {
|
|
370
|
+
const ops = diffOps(
|
|
371
|
+
base.map((e) => e.text),
|
|
372
|
+
side.map((e) => e.text),
|
|
373
|
+
);
|
|
374
|
+
const hunks: ChangeHunk[] = [];
|
|
375
|
+
let baseIdx = 0;
|
|
376
|
+
let sideIdx = 0;
|
|
377
|
+
let open: { baseStart: number; lines: LineEntry[] } | null = null;
|
|
378
|
+
const close = (): void => {
|
|
379
|
+
if (open === null) return;
|
|
380
|
+
hunks.push({ baseStart: open.baseStart, baseEnd: baseIdx, lines: open.lines });
|
|
381
|
+
open = null;
|
|
382
|
+
};
|
|
383
|
+
for (const op of ops) {
|
|
384
|
+
if (op === 0) {
|
|
385
|
+
close();
|
|
386
|
+
baseIdx++;
|
|
387
|
+
sideIdx++;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (open === null) open = { baseStart: baseIdx, lines: [] };
|
|
391
|
+
if (op === -1) baseIdx++;
|
|
392
|
+
else {
|
|
393
|
+
const entry = side[sideIdx];
|
|
394
|
+
if (entry) open.lines.push(entry);
|
|
395
|
+
sideIdx++;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
close();
|
|
399
|
+
return hunks;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** 冲突位置措辞(1-based,base 上的区间):单行/纯插入点 'line 7',多行 'line 12-14'。 */
|
|
403
|
+
function lineRange(start: number, end: number): string {
|
|
404
|
+
if (end <= start + 1) return 'line ' + (start + 1);
|
|
405
|
+
return 'line ' + (start + 1) + '-' + end;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function joinTexts(entries: LineEntry[]): string {
|
|
409
|
+
return entries.map((e) => e.text).join('\n');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 行级三方合并:base→local、base→remote 各自算变更区间,不重叠的全部应用;
|
|
414
|
+
* 重叠区间两侧结果逐字相同 → 收敛为一份,否则冲突(path='line X-Y')。
|
|
415
|
+
*/
|
|
416
|
+
export function mergeTextThreeWay(ancestor: string, local: string, remote: string): TextMergeResult {
|
|
417
|
+
const ancestorJson = tryParseJson(ancestor);
|
|
418
|
+
const localJson = tryParseJson(local);
|
|
419
|
+
const remoteJson = tryParseJson(remote);
|
|
420
|
+
if (ancestorJson.ok && localJson.ok && remoteJson.ok) {
|
|
421
|
+
const json = mergeJsonValues(ancestorJson.value, localJson.value, remoteJson.value);
|
|
422
|
+
if (json.ok) {
|
|
423
|
+
return { ok: true, merged: JSON.stringify(json.merged, null, 2) + '\n', conflicts: [], kind: 'json' };
|
|
424
|
+
}
|
|
425
|
+
return { ok: false, conflicts: json.conflicts, kind: 'json' };
|
|
426
|
+
}
|
|
427
|
+
return mergeTextLines(ancestor, local, remote);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function mergeTextLines(ancestor: string, local: string, remote: string): TextMergeResult {
|
|
431
|
+
const base = splitLines(ancestor);
|
|
432
|
+
const localHunks = changeHunks(base, splitLines(local));
|
|
433
|
+
const remoteHunks = changeHunks(base, splitLines(remote));
|
|
434
|
+
const preferredEol = detectEol(ancestor, local, remote);
|
|
435
|
+
const conflicts: TextMergeConflict[] = [];
|
|
436
|
+
const out: LineEntry[] = [];
|
|
437
|
+
let cursor = 0;
|
|
438
|
+
let i = 0;
|
|
439
|
+
let j = 0;
|
|
440
|
+
const emitSingle = (hunk: ChangeHunk): void => {
|
|
441
|
+
for (let k = cursor; k < hunk.baseStart; k++) {
|
|
442
|
+
const line = base[k];
|
|
443
|
+
if (line) out.push(line);
|
|
444
|
+
}
|
|
445
|
+
for (const line of hunk.lines) out.push(line);
|
|
446
|
+
cursor = hunk.baseEnd;
|
|
447
|
+
};
|
|
448
|
+
while (i < localHunks.length || j < remoteHunks.length) {
|
|
449
|
+
const a = localHunks[i];
|
|
450
|
+
const b = remoteHunks[j];
|
|
451
|
+
if (b === undefined) {
|
|
452
|
+
emitSingle(a!);
|
|
453
|
+
i++;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (a === undefined) {
|
|
457
|
+
emitSingle(b);
|
|
458
|
+
j++;
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
// 同一位置的纯插入(双侧都往这一点塞内容)必须按重叠处理:否则会把两份内容直接拼起来。
|
|
462
|
+
const bothEmptyAtSamePoint =
|
|
463
|
+
a.baseStart === a.baseEnd && b.baseStart === b.baseEnd && a.baseStart === b.baseStart;
|
|
464
|
+
if (!bothEmptyAtSamePoint) {
|
|
465
|
+
if (a.baseEnd <= b.baseStart) {
|
|
466
|
+
emitSingle(a);
|
|
467
|
+
i++;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (b.baseEnd <= a.baseStart) {
|
|
471
|
+
emitSingle(b);
|
|
472
|
+
j++;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// 重叠:交替扩张并集,直到两侧都没有 hunk 再与之相交
|
|
477
|
+
let start = Math.min(a.baseStart, b.baseStart);
|
|
478
|
+
let end = Math.max(a.baseEnd, b.baseEnd);
|
|
479
|
+
let ai = i;
|
|
480
|
+
let bj = j;
|
|
481
|
+
for (;;) {
|
|
482
|
+
let grew = false;
|
|
483
|
+
while (ai + 1 < localHunks.length && localHunks[ai + 1]!.baseStart < end) {
|
|
484
|
+
ai++;
|
|
485
|
+
end = Math.max(end, localHunks[ai]!.baseEnd);
|
|
486
|
+
grew = true;
|
|
487
|
+
}
|
|
488
|
+
while (bj + 1 < remoteHunks.length && remoteHunks[bj + 1]!.baseStart < end) {
|
|
489
|
+
bj++;
|
|
490
|
+
end = Math.max(end, remoteHunks[bj]!.baseEnd);
|
|
491
|
+
grew = true;
|
|
492
|
+
}
|
|
493
|
+
if (!grew) break;
|
|
494
|
+
}
|
|
495
|
+
const localLines: LineEntry[] = [];
|
|
496
|
+
for (let k = i; k <= ai; k++) localLines.push(...localHunks[k]!.lines);
|
|
497
|
+
const remoteLines: LineEntry[] = [];
|
|
498
|
+
for (let k = j; k <= bj; k++) remoteLines.push(...remoteHunks[k]!.lines);
|
|
499
|
+
for (let k = cursor; k < start; k++) {
|
|
500
|
+
const line = base[k];
|
|
501
|
+
if (line) out.push(line);
|
|
502
|
+
}
|
|
503
|
+
if (joinTexts(localLines) === joinTexts(remoteLines)) {
|
|
504
|
+
// 两侧改成逐字相同的结果 → 收敛为一份(不报冲突)
|
|
505
|
+
for (const line of localLines) out.push(line);
|
|
506
|
+
} else {
|
|
507
|
+
conflicts.push({
|
|
508
|
+
path: lineRange(start, end),
|
|
509
|
+
reason: 'overlap',
|
|
510
|
+
local: joinTexts(localLines),
|
|
511
|
+
remote: joinTexts(remoteLines),
|
|
512
|
+
ancestor: joinTexts(base.slice(start, end)),
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
cursor = end;
|
|
516
|
+
i = ai + 1;
|
|
517
|
+
j = bj + 1;
|
|
518
|
+
}
|
|
519
|
+
for (let k = cursor; k < base.length; k++) {
|
|
520
|
+
const line = base[k];
|
|
521
|
+
if (line) out.push(line);
|
|
522
|
+
}
|
|
523
|
+
if (conflicts.length > 0) return { ok: false, conflicts, kind: 'text' };
|
|
524
|
+
return { ok: true, merged: rebuildLines(out, preferredEol), conflicts: [], kind: 'text' };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
// 字节级入口
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* 字节三方合并:
|
|
533
|
+
* - ancestor === undefined → ok:false(无共同祖先,由上层按整文件冲突处理,绝不猜);
|
|
534
|
+
* - 任一侧二进制 / 超限(含 ancestor)→ ok:false,kind 如实('binary' / 'too-large');
|
|
535
|
+
* - 否则解码为文本走 mergeTextThreeWay,成功后按 UTF-8 编码回字节。
|
|
536
|
+
*/
|
|
537
|
+
export function mergeBytesThreeWay(
|
|
538
|
+
ancestor: Uint8Array | undefined,
|
|
539
|
+
local: Uint8Array,
|
|
540
|
+
remote: Uint8Array,
|
|
541
|
+
): TextBytesMergeResult {
|
|
542
|
+
if (ancestor === undefined) {
|
|
543
|
+
return { ok: false, conflicts: [], kind: worstKind([classifyBytes(local), classifyBytes(remote)]) };
|
|
544
|
+
}
|
|
545
|
+
const kind = worstKind([classifyBytes(ancestor), classifyBytes(local), classifyBytes(remote)]);
|
|
546
|
+
if (kind === 'binary' || kind === 'too-large') {
|
|
547
|
+
return { ok: false, conflicts: [{ path: '', reason: kind }], kind };
|
|
548
|
+
}
|
|
549
|
+
const result = mergeTextThreeWay(decodeUtf8(ancestor), decodeUtf8(local), decodeUtf8(remote));
|
|
550
|
+
if (!result.ok || result.merged === undefined) {
|
|
551
|
+
return { ok: false, conflicts: result.conflicts, kind: result.kind };
|
|
552
|
+
}
|
|
553
|
+
return { ok: true, merged: new TextEncoder().encode(result.merged), conflicts: [], kind: result.kind };
|
|
554
|
+
}
|
package/src/sync/transport.ts
CHANGED
|
@@ -35,6 +35,15 @@ export interface ManifestSummary {
|
|
|
35
35
|
* 使旧快照无法复活已删除的对话。缺省 = 旧快照(无墓碑信息,行为与改造前一致)。
|
|
36
36
|
*/
|
|
37
37
|
deletedSessions?: string[];
|
|
38
|
+
/**
|
|
39
|
+
* 产生该快照的**设备身份**(同步历史展示「哪台机器推的」)。
|
|
40
|
+
*
|
|
41
|
+
* deviceId 是本机首次同步时生成并持久化在 `sync-state.json` 的随机 id(稳定标识,
|
|
42
|
+
* 换主机名也不变);deviceName 是人类可读名(缺省取主机名)。
|
|
43
|
+
* 缺省 undefined = 旧快照 / 未记录(不猜,行为与改造前一致)。
|
|
44
|
+
*/
|
|
45
|
+
deviceId?: string;
|
|
46
|
+
deviceName?: string;
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
/** 快照元信息:list() 条目 / upload() 返回值 */
|
|
@@ -87,6 +96,30 @@ export interface SyncSnapshot {
|
|
|
87
96
|
* 远端快照传输通道契约。
|
|
88
97
|
* 实现约定:同 id 重复 upload 视为覆盖(幂等友好);download 对不存在的 id 必须抛错。
|
|
89
98
|
*/
|
|
99
|
+
/**
|
|
100
|
+
* 连通性探针结果(issue:落地 cloud-sync 借鉴项 ②)。
|
|
101
|
+
*
|
|
102
|
+
* 探针是**只读、零副作用**的自检:验证凭据、目标容器可达、以及(webdav)目标目录
|
|
103
|
+
* 是否存在。失败**必须抛 SyncTransportError**(带 kind/status),由调用方按统一错误面
|
|
104
|
+
* 渲染 —— 本类型只描述成功时的自述信息。
|
|
105
|
+
*/
|
|
106
|
+
export interface SyncProbeResult {
|
|
107
|
+
/** 人类可读的一句话结论(已本地化;可直接展示) */
|
|
108
|
+
message: string;
|
|
109
|
+
/** 可执行的下一步提示(如「region 应为 cn-hangzhou」);无则省略 */
|
|
110
|
+
hint?: string;
|
|
111
|
+
/** 探测到的远端快照数量(读得到索引时给出;读不到则省略,绝不猜 0) */
|
|
112
|
+
snapshotCount?: number;
|
|
113
|
+
/** 本次探测顺带创建的目标目录层级(webdav 首连自建;无则为空数组) */
|
|
114
|
+
createdCollections?: string[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 写出/删除路径的**条件写**失败(HTTP 412 Precondition Failed,或 S3 兼容端点的 409):
|
|
118
|
+
* 远端在我读取之后被别人改过 —— 必须中止本次写入让用户重试,绝不静默覆盖。 */
|
|
119
|
+
export function isPreconditionFailedStatus(status: number): boolean {
|
|
120
|
+
return status === 412;
|
|
121
|
+
}
|
|
122
|
+
|
|
90
123
|
export interface SyncTransport {
|
|
91
124
|
readonly type: string;
|
|
92
125
|
/** 列出远端已有快照(按 createdAt 升序) */
|
|
@@ -97,6 +130,13 @@ export interface SyncTransport {
|
|
|
97
130
|
download(id: string): Promise<SyncSnapshot>;
|
|
98
131
|
/** 删除远端快照(不存在视为成功) */
|
|
99
132
|
delete(id: string): Promise<void>;
|
|
133
|
+
/**
|
|
134
|
+
* 连通性探针(只读自检;可选实现 —— git 通道按 fast-forward 语义自证,不实现即视为不支持)。
|
|
135
|
+
*
|
|
136
|
+
* 约定:**不写任何快照数据**(webdav 允许幂等 MKCOL 补齐目标目录,这是唯一被允许的写副作用,
|
|
137
|
+
* 因为「远端目录不存在」正是它要修的故障);凭据/权限/容器问题一律抛 SyncTransportError。
|
|
138
|
+
*/
|
|
139
|
+
probe?(): Promise<SyncProbeResult>;
|
|
100
140
|
}
|
|
101
141
|
|
|
102
142
|
/* ---------------- 统一错误面 / 重试 / 超时(两条通道共用,t24) ---------------- */
|
|
@@ -132,6 +172,14 @@ export interface SyncTransportErrorOptions {
|
|
|
132
172
|
/** HTTP 状态码(webdav 通道;git 通道无) */
|
|
133
173
|
status?: number;
|
|
134
174
|
cause?: unknown;
|
|
175
|
+
/**
|
|
176
|
+
* 稳定的**机器可判定**错误码(可选)。
|
|
177
|
+
*
|
|
178
|
+
* 为什么不用 message 判:文案会随语言与措辞变,路由/UI 需要的是「这是哪一种情况」。
|
|
179
|
+
* 当前唯一使用点 = `probe-unsupported`(该通道没实现连通性自检 ⇒ 这是**能力边界**,不是故障,
|
|
180
|
+
* 界面必须按中性提示渲染而不是红色「操作失败」)。
|
|
181
|
+
*/
|
|
182
|
+
code?: string;
|
|
135
183
|
}
|
|
136
184
|
|
|
137
185
|
/**
|
|
@@ -143,6 +191,7 @@ export class SyncTransportError extends Error {
|
|
|
143
191
|
readonly kind: SyncTransportErrorKind;
|
|
144
192
|
readonly retryable: boolean;
|
|
145
193
|
readonly status: number | undefined;
|
|
194
|
+
readonly code: string | undefined;
|
|
146
195
|
|
|
147
196
|
constructor(message: string, opts: SyncTransportErrorOptions = {}) {
|
|
148
197
|
super(message, opts.cause === undefined ? undefined : { cause: opts.cause });
|
|
@@ -150,6 +199,7 @@ export class SyncTransportError extends Error {
|
|
|
150
199
|
this.kind = opts.kind ?? 'unknown';
|
|
151
200
|
this.retryable = opts.retryable ?? RETRYABLE_KINDS.includes(this.kind);
|
|
152
201
|
this.status = opts.status;
|
|
202
|
+
this.code = opts.code;
|
|
153
203
|
}
|
|
154
204
|
}
|
|
155
205
|
|
|
@@ -158,6 +208,13 @@ export function isSyncTransportError(err: unknown): err is SyncTransportError {
|
|
|
158
208
|
return err instanceof SyncTransportError;
|
|
159
209
|
}
|
|
160
210
|
|
|
211
|
+
/** 该通道**不提供**连通性自检(能力边界,不是故障):路由/UI 据此改用中性提示。 */
|
|
212
|
+
export const PROBE_UNSUPPORTED_CODE = 'probe-unsupported';
|
|
213
|
+
|
|
214
|
+
export function isProbeUnsupported(err: unknown): boolean {
|
|
215
|
+
return err instanceof SyncTransportError && err.code === PROBE_UNSUPPORTED_CODE;
|
|
216
|
+
}
|
|
217
|
+
|
|
161
218
|
/** 是否值得重试:只有带可重试分类的传输层错误才重试;未知错误一律不重试。 */
|
|
162
219
|
export function isRetryableTransportError(err: unknown): boolean {
|
|
163
220
|
return err instanceof SyncTransportError && err.retryable;
|