dsh-config-manager 0.1.78 → 0.1.80
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 +401 -112
- package/lib/client.js +2899 -1525
- 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/ancestor.d.ts +22 -0
- package/lib/sync/ancestor.js +66 -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 +18 -1
- package/lib/sync/gist/gist-transport.js +42 -3
- package/lib/sync/gist/messages.d.ts +2 -0
- package/lib/sync/gist/messages.js +5 -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/s3/sigv4.js +11 -2
- package/lib/sync/sync-engine.d.ts +212 -8
- package/lib/sync/sync-engine.js +514 -58
- 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 +9 -0
- package/lib/ui/i18n.js +22 -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/api-base.test.ts +36 -0
- package/src/client/common/http.test.ts +12 -1
- package/src/client/common/http.ts +8 -2
- package/src/client/common/routes.ts +46 -3
- 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/market/market-api.test.ts +3 -2
- 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 +220 -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 +248 -0
- package/src/client/sync/merge-view.ts +398 -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.test.ts +5 -3
- 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/ancestor.ts +76 -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 +203 -0
- package/src/sync/gist/gist-transport.ts +53 -5
- package/src/sync/gist/messages.ts +5 -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.test.ts +2 -1
- package/src/sync/s3/s3-transport.ts +87 -9
- package/src/sync/s3/sigv4.test.ts +4 -1
- package/src/sync/s3/sigv4.ts +11 -2
- package/src/sync/sync-device-identity.test.ts +267 -0
- package/src/sync/sync-engine.test.ts +410 -19
- package/src/sync/sync-engine.ts +602 -57
- 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 +22 -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
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
SyncTransportError, withSyncRetry,
|
|
36
36
|
} from '../transport.ts';
|
|
37
37
|
import type {
|
|
38
|
-
SyncRetryOptions, SyncSnapshot, SyncSnapshotMeta, SyncTransport, SyncTransportErrorOptions,
|
|
38
|
+
SyncProbeResult, SyncRetryOptions, SyncSnapshot, SyncSnapshotMeta, SyncTransport, SyncTransportErrorOptions,
|
|
39
39
|
} from '../transport.ts';
|
|
40
40
|
import { parseJsonSafe } from '../../utils/json.ts';
|
|
41
41
|
import { atomicWriteFile } from '../../utils/atomic-write.ts';
|
|
@@ -77,6 +77,14 @@ export interface GitWorkDirFacts {
|
|
|
77
77
|
ahead: number | null;
|
|
78
78
|
/** 工作树是否有未提交改动(读不出 → null) */
|
|
79
79
|
dirty: boolean | null;
|
|
80
|
+
/**
|
|
81
|
+
* 远端跟踪分支上的快照数(issue #104 建议 3):`snapshots/` 与 `snapshots-encrypted/`
|
|
82
|
+
* 下的直接子项数(目录,或加密通道的 <id>.json 文件)。它和 `ahead` 一起回答
|
|
83
|
+
* 「本地领先 N 个提交、可远端从来没收到过快照吗」—— 那正是 issue #104 里
|
|
84
|
+
* 远端两周一个字节都没收到、用户却以为在正常备份的现场。
|
|
85
|
+
* 只有 detail 自检时给出;无远端跟踪分支 / 读不出 → null。
|
|
86
|
+
*/
|
|
87
|
+
remoteSnapshots: number | null;
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
/** 凭据提供者:token 只从这里读取,绝不在任何地方持久化 */
|
|
@@ -130,6 +138,11 @@ const SNAPSHOTS_ENCRYPTED_REL = 'snapshots-encrypted';
|
|
|
130
138
|
* 内容没变 = 文件路径已存在 = 一个字都不写、也不进本次 diff。
|
|
131
139
|
*/
|
|
132
140
|
const BLOBS_REL = 'blobs';
|
|
141
|
+
/**
|
|
142
|
+
* 同步仓库(git 通道)顶层**只可能出现**这三个条目。issue #104 的 `adopt` 模式拿它当安全判据:
|
|
143
|
+
* 远端分支的顶层出现别的名字 = 这不是本插件的同步仓库,合并它等于把别人的仓库内容搬进工作副本。
|
|
144
|
+
*/
|
|
145
|
+
const ADOPT_ROOT_ALLOW = new Set<string>([SNAPSHOTS_REL, SNAPSHOTS_ENCRYPTED_REL, BLOBS_REL]);
|
|
133
146
|
const DEFAULT_AUTHOR: GitAuthor = { name: 'DSH Config Sync', email: 'sync@dsh.local' };
|
|
134
147
|
const DEFAULT_CREDENTIAL_USERNAME = 'oauth2';
|
|
135
148
|
/** 快照 id 安全字符集:字母数字开头,仅 . _ -;防路径穿越与 commit message 注入 */
|
|
@@ -410,6 +423,20 @@ export class GitTransport implements SyncTransport {
|
|
|
410
423
|
throw new GitTransportError(this.msg('sync.git.repoUnreachable', { url: this.mask(this.o.repoUrl, null) }));
|
|
411
424
|
}
|
|
412
425
|
|
|
426
|
+
/**
|
|
427
|
+
* 连通性探针(cloud-sync 借鉴项 ②):只读自检「远端可达 + 凭据可用」。
|
|
428
|
+
*
|
|
429
|
+
* 为什么复用 ls-remote:它不需要工作副本、不写任何本地状态,而且正是 clone/fetch 之前的那一步 ——
|
|
430
|
+
* 远端不可达 / 令牌失效 / SSH 主机密钥不信任都会在这里显式失败(checkIsPrivate 抛可操作文案),
|
|
431
|
+
* 因此结论与随后真正同步时的成败**同源**。
|
|
432
|
+
* 刻意**不**统计远端快照数:那要先 fetch 工作副本(对本地有写入),超出「只读自检」的范围 ——
|
|
433
|
+
* 宁可不报,也不报一个会误导的 0。
|
|
434
|
+
*/
|
|
435
|
+
async probe(): Promise<SyncProbeResult> {
|
|
436
|
+
await this.checkIsPrivate();
|
|
437
|
+
return { message: this.msg('sync.git.probeOk') };
|
|
438
|
+
}
|
|
439
|
+
|
|
413
440
|
/* ---------------- 内部实现 ---------------- */
|
|
414
441
|
|
|
415
442
|
private async ensureRepo(): Promise<void> {
|
|
@@ -501,7 +528,7 @@ export class GitTransport implements SyncTransport {
|
|
|
501
528
|
async inspectWorkDir(opts: { detail?: boolean } = {}): Promise<GitWorkDirFacts> {
|
|
502
529
|
const fsx = createSnapshotFs();
|
|
503
530
|
if (!(await fsx.exists(path.join(this.o.workDir, '.git')))) {
|
|
504
|
-
return { path: this.o.workDir, exists: false, origin: null, pushUrl: null, match: false, ahead: null, dirty: null };
|
|
531
|
+
return { path: this.o.workDir, exists: false, origin: null, pushUrl: null, match: false, ahead: null, dirty: null, remoteSnapshots: null };
|
|
505
532
|
}
|
|
506
533
|
const originRes = await this.runGit(['remote', 'get-url', 'origin'], { allowNonZero: true });
|
|
507
534
|
const origin = originRes.code === 0 ? originRes.stdout.trim() : '';
|
|
@@ -509,6 +536,7 @@ export class GitTransport implements SyncTransport {
|
|
|
509
536
|
const pushRaw = pushRes.code === 0 ? pushRes.stdout.trim() : '';
|
|
510
537
|
let ahead: number | null = null;
|
|
511
538
|
let dirty: boolean | null = null;
|
|
539
|
+
let remoteSnapshots: number | null = null;
|
|
512
540
|
if (opts.detail === true) {
|
|
513
541
|
// 未上行的本地提交:HEAD 可达、但任何远端跟踪引用都不可达的提交数(无 upstream 也能算)
|
|
514
542
|
const aheadRes = await this.runGit(['rev-list', '--count', 'HEAD', '--not', '--remotes'], { allowNonZero: true });
|
|
@@ -516,6 +544,27 @@ export class GitTransport implements SyncTransport {
|
|
|
516
544
|
ahead = Number.isFinite(parsed) ? parsed : null;
|
|
517
545
|
const statusRes = await this.runGit(['status', '--porcelain'], { allowNonZero: true });
|
|
518
546
|
dirty = statusRes.code === 0 ? statusRes.stdout.trim() !== '' : null;
|
|
547
|
+
// issue #104 建议 3:远端分支上到底有几个快照。只读 refs/remotes/origin/<branch>
|
|
548
|
+
// (上次 fetch / push 的结果),不发网络请求 —— 两条本地只读命令。
|
|
549
|
+
// 注意不能写 `ls-tree origin/<branch> -- snapshots`:带 pathspec 时它只回显目录条目
|
|
550
|
+
// 本身(`snapshots`),数出来永远是 1。必须用 `<ref>:<dir>` 取该树的直接子项。
|
|
551
|
+
const branchRes = await this.runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { allowNonZero: true });
|
|
552
|
+
const branch = branchRes.code === 0 ? branchRes.stdout.trim() : '';
|
|
553
|
+
if (branch !== '' && branch !== 'HEAD') {
|
|
554
|
+
const countTree = async (treeish: string): Promise<number | null> => {
|
|
555
|
+
const treeRes = await this.runGit(['ls-tree', '--name-only', treeish], { allowNonZero: true });
|
|
556
|
+
if (treeRes.code !== 0) return null;
|
|
557
|
+
return treeRes.stdout
|
|
558
|
+
.split('\n')
|
|
559
|
+
.map((line) => line.trim())
|
|
560
|
+
// 每个快照是一级子项;`.gitkeep` 只是占位,不是快照
|
|
561
|
+
.filter((line) => line !== '' && !line.endsWith(SNAPSHOT_KEEP_FILE)).length;
|
|
562
|
+
};
|
|
563
|
+
const plain = await countTree(`origin/${branch}:${SNAPSHOTS_REL}`);
|
|
564
|
+
const encrypted = await countTree(`origin/${branch}:${SNAPSHOTS_ENCRYPTED_REL}`);
|
|
565
|
+
// 两个目录都不存在 = 远端分支根本没有快照布局(0);分支/引用读不出 = null(不猜)
|
|
566
|
+
remoteSnapshots = plain === null && encrypted === null ? 0 : (plain ?? 0) + (encrypted ?? 0);
|
|
567
|
+
}
|
|
519
568
|
}
|
|
520
569
|
return {
|
|
521
570
|
path: this.o.workDir,
|
|
@@ -526,18 +575,21 @@ export class GitTransport implements SyncTransport {
|
|
|
526
575
|
match: origin === this.o.repoUrl && (pushRaw === '' || pushRaw === this.o.repoUrl),
|
|
527
576
|
ahead,
|
|
528
577
|
dirty,
|
|
578
|
+
remoteSnapshots,
|
|
529
579
|
};
|
|
530
580
|
}
|
|
531
581
|
|
|
532
582
|
/**
|
|
533
|
-
* 重建工作副本(issue #79 的自救入口):
|
|
583
|
+
* 重建工作副本(issue #79 / #104 的自救入口):
|
|
534
584
|
* - `relink`:只把 origin / push URL 重新指向 repoUrl(**非破坏性**:不碰工作树与本地提交);
|
|
585
|
+
* - `adopt`:把远端分支**合并进**现有工作副本(issue #104:换成另一个仓库、或远端历史被重写后,
|
|
586
|
+
* 两条历史不相交,`pull --ff-only` 永远失败;这里合并后本地提交一条都不丢,可 revert);
|
|
535
587
|
* - `reclone`:删除工作副本目录后重新 clone(**丢弃未上行的本地提交**;调用方必须先让用户确认)。
|
|
536
588
|
*
|
|
537
589
|
* 安全边界:`reclone` 只在目录**确实是 git 工作副本**(有 .git)时才删;目录非空且无 .git
|
|
538
590
|
* 一律拒绝(与 ensureRepo 同一句 workDirNotRepo)—— 绝不删来路不明的目录。
|
|
539
591
|
*/
|
|
540
|
-
async rebuildWorkDir(mode: 'relink' | 'reclone'): Promise<void> {
|
|
592
|
+
async rebuildWorkDir(mode: 'relink' | 'adopt' | 'reclone'): Promise<void> {
|
|
541
593
|
this.repoReady = false;
|
|
542
594
|
if (mode === 'relink') {
|
|
543
595
|
await this.ensureRepo(); // .git 存在 → reconcileOrigin
|
|
@@ -556,10 +608,121 @@ export class GitTransport implements SyncTransport {
|
|
|
556
608
|
await this.ensureRepo(); // 空目录 → 直接 clone
|
|
557
609
|
return;
|
|
558
610
|
}
|
|
611
|
+
if (mode === 'adopt') {
|
|
612
|
+
// 已有工作副本但历史与远端不相交:合并远端历史,不删任何本地提交
|
|
613
|
+
await this.adoptRemoteHistory();
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
559
616
|
await fs.rm(this.o.workDir, { recursive: true, force: true });
|
|
560
617
|
await this.ensureRepo();
|
|
561
618
|
}
|
|
562
619
|
|
|
620
|
+
/**
|
|
621
|
+
* 采纳远端历史(issue #104):把远端分支合并进现有工作副本,解开「换了仓库 / 远端历史被重写」
|
|
622
|
+
* 之后 `pull --ff-only` 的死结(真机形态:`+ 8e87c0c...dc69fc7 main (forced update)` 后
|
|
623
|
+
* `fatal: Not possible to fast-forward, aborting.`)。
|
|
624
|
+
*
|
|
625
|
+
* 为什么 merge 而不是 issue 里建议的 `rebase --root --onto`:
|
|
626
|
+
* - rebase 会**改写本地提交**(sha 全变),出问题时无法 revert,中途失败还留下 detached HEAD
|
|
627
|
+
* 与 .git/rebase-merge 目录,状态比失败前更难收拾;
|
|
628
|
+
* - merge 只多一个 merge commit,`git revert -m 1` 可原路退回,冲突时 `merge --abort`
|
|
629
|
+
* 能干净回到起点(真机裸仓验证:冲突 abort 后无 MERGE_HEAD、工作树零改动)。
|
|
630
|
+
*
|
|
631
|
+
* 三条安全边界(宁可不做,也不把别人的仓库合进来):
|
|
632
|
+
* ① 工作树必须干净 —— 有未提交改动时合并会把用户改动卡在冲突里;
|
|
633
|
+
* ② 远端分支顶层只允许快照布局(snapshots / snapshots-encrypted / blobs)—— 否则这是个
|
|
634
|
+
* 「另有用途的仓库」,合并等于把它整个搬进工作副本;
|
|
635
|
+
* ③ 远端必须存在同名分支 —— 远端是空仓库(没有任何分支)时视为没什么可采纳,正常返回
|
|
636
|
+
* (后续 push 会自己建分支);有分支但名字对不上则拒绝猜,把远端分支名列出来。
|
|
637
|
+
*/
|
|
638
|
+
private async adoptRemoteHistory(): Promise<void> {
|
|
639
|
+
// ① 未提交改动:不替用户丢改动
|
|
640
|
+
const statusRes = await this.runGit(['status', '--porcelain'], { allowNonZero: true });
|
|
641
|
+
if (statusRes.code !== 0) {
|
|
642
|
+
throw new GitTransportError(
|
|
643
|
+
this.msg('sync.git.cmdFailed', {
|
|
644
|
+
args: 'status --porcelain',
|
|
645
|
+
code: String(statusRes.code),
|
|
646
|
+
err: this.mask(statusRes.stderr, await this.readTokenOnce()),
|
|
647
|
+
}),
|
|
648
|
+
{ kind: 'unknown', retryable: true },
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
if (statusRes.stdout.trim() !== '') {
|
|
652
|
+
throw new GitTransportError(
|
|
653
|
+
this.msg('sync.git.adoptDirty', { dir: this.o.workDir }),
|
|
654
|
+
{ kind: 'protocol', retryable: false },
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
await this.reconcileOrigin();
|
|
658
|
+
const branchRes = await this.runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { allowNonZero: true });
|
|
659
|
+
const branch = branchRes.code === 0 ? branchRes.stdout.trim() : '';
|
|
660
|
+
if (branch === '' || branch === 'HEAD') {
|
|
661
|
+
// detached HEAD:没有分支可挂比 upstream,合并也不会被 push 走 → 拒绝并说明
|
|
662
|
+
throw new GitTransportError(
|
|
663
|
+
this.msg('sync.git.adoptDetached', { dir: this.o.workDir }),
|
|
664
|
+
{ kind: 'protocol', retryable: false },
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
// fetch 是网络操作(幂等读)→ 允许有限指数退避重试
|
|
668
|
+
await withSyncRetry(async () => {
|
|
669
|
+
await this.runGit(['fetch', 'origin'], { withCredential: true });
|
|
670
|
+
}, this.o.retry);
|
|
671
|
+
const remoteRef = `refs/remotes/origin/${branch}`;
|
|
672
|
+
const refRes = await this.runGit(['rev-parse', '--verify', '--quiet', remoteRef], { allowNonZero: true });
|
|
673
|
+
if (refRes.code !== 0) {
|
|
674
|
+
// 远端没有同名分支:用 ls-remote 区分「远端是空仓库」与「分支名不同」
|
|
675
|
+
const headsRes = await this.runGit(['ls-remote', '--heads', 'origin'], { withCredential: true, allowNonZero: true });
|
|
676
|
+
const heads = (headsRes.code === 0 ? headsRes.stdout : '')
|
|
677
|
+
.split('\n')
|
|
678
|
+
.map((line) => line.trim().split('\t')[1] ?? '')
|
|
679
|
+
.filter((ref) => ref.startsWith('refs/heads/'))
|
|
680
|
+
.map((ref) => ref.slice('refs/heads/'.length));
|
|
681
|
+
if (heads.length === 0) {
|
|
682
|
+
this.repoReady = true; // 空仓库:没有可采纳的历史,后续 push 会建分支
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
throw new GitTransportError(
|
|
686
|
+
this.msg('sync.git.adoptNoRemoteBranch', { branch, heads: heads.slice(0, 3).join(', ') }),
|
|
687
|
+
{ kind: 'protocol', retryable: false },
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
// ② 远端顶层只能是快照布局
|
|
691
|
+
const treeRes = await this.runGit(['ls-tree', '--name-only', `origin/${branch}`], { allowNonZero: true });
|
|
692
|
+
if (treeRes.code !== 0) {
|
|
693
|
+
throw new GitTransportError(
|
|
694
|
+
this.msg('sync.git.cmdFailed', {
|
|
695
|
+
args: `ls-tree --name-only origin/${branch}`,
|
|
696
|
+
code: String(treeRes.code),
|
|
697
|
+
err: this.mask(treeRes.stderr, await this.readTokenOnce()),
|
|
698
|
+
}),
|
|
699
|
+
{ kind: 'unknown', retryable: true },
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
const unknownEntries = treeRes.stdout
|
|
703
|
+
.split('\n')
|
|
704
|
+
.map((name) => name.trim().replace(/\/$/, ''))
|
|
705
|
+
.filter((name) => name !== '' && !ADOPT_ROOT_ALLOW.has(name));
|
|
706
|
+
if (unknownEntries.length > 0) {
|
|
707
|
+
throw new GitTransportError(
|
|
708
|
+
this.msg('sync.git.adoptNotSyncRepo', { entries: unknownEntries.slice(0, 3).join(', ') }),
|
|
709
|
+
{ kind: 'protocol', retryable: false },
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
// ③ 合并(不改写任何本地提交);冲突则 abort 回起点再报错
|
|
713
|
+
const mergeRes = await this.runGit(['merge', '--allow-unrelated-histories', '--no-edit', `origin/${branch}`], { allowNonZero: true });
|
|
714
|
+
if (mergeRes.code !== 0) {
|
|
715
|
+
await this.runGit(['merge', '--abort'], { allowNonZero: true });
|
|
716
|
+
throw new GitTransportError(
|
|
717
|
+
this.msg('sync.git.adoptMergeConflict', { branch }),
|
|
718
|
+
{ kind: 'conflict', retryable: false },
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
// 让后续 pull --ff-only 有 upstream 可用(忽略失败:分支可能已被删/重命名)
|
|
722
|
+
await this.runGit(['branch', '--set-upstream-to', `origin/${branch}`, branch], { allowNonZero: true });
|
|
723
|
+
this.repoReady = true;
|
|
724
|
+
}
|
|
725
|
+
|
|
563
726
|
/**
|
|
564
727
|
* pull --ff-only 同步远端;无本地提交(全新仓库)或无 upstream 时静默跳过。
|
|
565
728
|
* opts.retryable=true(仅 list / download 等**幂等读**路径传):瞬时网络故障走有限指数退避重试。
|
|
@@ -666,7 +829,17 @@ export class GitTransport implements SyncTransport {
|
|
|
666
829
|
const fileArg = quoteGitValue(credFile.replace(/\\/g, '/'));
|
|
667
830
|
await atomicWriteFile(credFile, `https://${this.o.credentialUsername}:${token}@${host}\n`, { mode: 0o600, symlink: 'reject' });
|
|
668
831
|
return {
|
|
669
|
-
|
|
832
|
+
// issue #103:**必须**把用户名也钉进 git 参数。git credential store 用 URL 里的 username
|
|
833
|
+
// 作为查找键;store 文件写的是本插件自己的用户名(默认 oauth2),但 git 会先读全局
|
|
834
|
+
// `.gitconfig` 里的 `credential.<url>.username`(Git Credential Manager / gh CLI 都会写这一行),
|
|
835
|
+
// 用那个用户名去查 store —— 键对不上就查不到口令,无 tty 时退化成
|
|
836
|
+
// `fatal: could not read Password for 'https://<全局用户名>@host'`。
|
|
837
|
+
// 钉住 username 后查找键与 store 文件里的值同源(都来自 credentialUsername)。
|
|
838
|
+
extraArgs: [
|
|
839
|
+
'-c', 'credential.helper=',
|
|
840
|
+
'-c', `credential.helper=store --file=${fileArg}`,
|
|
841
|
+
'-c', `credential.username=${this.o.credentialUsername}`,
|
|
842
|
+
],
|
|
670
843
|
token,
|
|
671
844
|
cleanup: async () => { await fs.rm(tmpDir, { recursive: true, force: true }); },
|
|
672
845
|
};
|
package/src/sync/merge.test.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* m-sync-flow:三方合并引擎测试。
|
|
3
3
|
* 覆盖:
|
|
4
|
-
* - 分区级四象限(useRemote / keepLocal / skip / conflict
|
|
5
|
-
* - JSON
|
|
6
|
-
* - FilesSection
|
|
7
|
-
* - MergePlan
|
|
4
|
+
* - 分区级四象限(useRemote / keepLocal / skip / conflict)+ 精细合并成功的 'merge' 决策
|
|
5
|
+
* - JSON 分区递归合并(嵌套对象/未知字段保留/数组保守冲突/冲突路径到叶子)
|
|
6
|
+
* - FilesSection 逐文件合并(文本行级三方合并 / 二进制保留冲突 / 删除-修改冲突)
|
|
7
|
+
* - MergePlan 输出形状与 MergeConflict.reason
|
|
8
8
|
* - 无祖先退化为整分区冲突
|
|
9
9
|
*/
|
|
10
10
|
import test from 'node:test';
|
|
@@ -22,13 +22,22 @@ const settings = (ns: Record<string, { value: unknown; revision: number }>): Set
|
|
|
22
22
|
|
|
23
23
|
const files = (entries: Array<[string, string]>): FilesSection => ({
|
|
24
24
|
version: 1,
|
|
25
|
-
files: entries.map(([
|
|
25
|
+
files: entries.map(([pth, c]) => ({ relativePath: pth, data: new TextEncoder().encode(c), contentHash: '' })),
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
const binFiles = (entries: Array<[string, Uint8Array]>): FilesSection => ({
|
|
29
|
+
version: 1,
|
|
30
|
+
files: entries.map(([pth, data]) => ({ relativePath: pth, data, contentHash: '' })),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const decode = (s: FilesSection, path: string): string | undefined => {
|
|
34
|
+
const f = s.files.find((x) => x.relativePath === path);
|
|
35
|
+
return f === undefined ? undefined : new TextDecoder().decode(f.data);
|
|
36
|
+
};
|
|
37
|
+
|
|
28
38
|
test('merge: 本地==远端 → skip', () => {
|
|
29
39
|
const s = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
|
|
30
|
-
const
|
|
31
|
-
const plan = merge({ settings: s as SectionData }, { settings: s as SectionData }, { settings: a as SectionData });
|
|
40
|
+
const plan = merge({ settings: s as SectionData }, { settings: s as SectionData }, { settings: s as SectionData });
|
|
32
41
|
assert.equal(plan.sections.length, 1);
|
|
33
42
|
assert.equal(plan.sections[0]!.decision, 'skip');
|
|
34
43
|
assert.equal(plan.sections[0]!.conflicts.length, 0);
|
|
@@ -36,10 +45,9 @@ test('merge: 本地==远端 → skip', () => {
|
|
|
36
45
|
|
|
37
46
|
test('merge: 本地==祖先 & 远端!=祖先 → useRemote', () => {
|
|
38
47
|
const anc = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
|
|
39
|
-
const loc = anc;
|
|
40
48
|
const rem = settings({ general: { value: { theme: 'light' }, revision: 2 } });
|
|
41
49
|
const plan = merge(
|
|
42
|
-
{ settings:
|
|
50
|
+
{ settings: anc as SectionData },
|
|
43
51
|
{ settings: rem as SectionData },
|
|
44
52
|
{ settings: anc as SectionData },
|
|
45
53
|
);
|
|
@@ -50,17 +58,16 @@ test('merge: 本地==祖先 & 远端!=祖先 → useRemote', () => {
|
|
|
50
58
|
test('merge: 本地!=祖先 & 远端==祖先 → keepLocal', () => {
|
|
51
59
|
const anc = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
|
|
52
60
|
const loc = settings({ general: { value: { theme: 'custom' }, revision: 2 } });
|
|
53
|
-
const rem = anc;
|
|
54
61
|
const plan = merge(
|
|
55
62
|
{ settings: loc as SectionData },
|
|
56
|
-
{ settings:
|
|
63
|
+
{ settings: anc as SectionData },
|
|
57
64
|
{ settings: anc as SectionData },
|
|
58
65
|
);
|
|
59
66
|
assert.equal(plan.sections[0]!.decision, 'keepLocal');
|
|
60
67
|
assert.deepEqual((plan.sections[0]!.merged as SettingsSection).namespaces.general!.value, { theme: 'custom' });
|
|
61
68
|
});
|
|
62
69
|
|
|
63
|
-
test('merge:
|
|
70
|
+
test('merge: 双侧相对祖先都改且同处不同值 → conflict(路径精确到叶子)', () => {
|
|
64
71
|
const anc = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
|
|
65
72
|
const loc = settings({ general: { value: { theme: 'blue' }, revision: 2 } });
|
|
66
73
|
const rem = settings({ general: { value: { theme: 'red' }, revision: 2 } });
|
|
@@ -70,7 +77,11 @@ test('merge: 双侧相对祖先都改且分区 hash 不同 → conflict', () =>
|
|
|
70
77
|
{ settings: anc as SectionData },
|
|
71
78
|
);
|
|
72
79
|
assert.equal(plan.sections[0]!.decision, 'conflict');
|
|
73
|
-
assert.
|
|
80
|
+
assert.equal(plan.sections[0]!.merged, undefined, '冲突时绝不回传半成品 merged');
|
|
81
|
+
assert.equal(plan.sections[0]!.conflicts.length, 1);
|
|
82
|
+
assert.equal(plan.sections[0]!.conflicts[0]!.path, 'namespaces.general.value.theme');
|
|
83
|
+
assert.equal(plan.sections[0]!.conflicts[0]!.kind, 'key');
|
|
84
|
+
assert.equal(plan.sections[0]!.conflicts[0]!.reason, 'both-modified');
|
|
74
85
|
});
|
|
75
86
|
|
|
76
87
|
test('merge: 无 ancestor → 整分区 conflict(不猜测)', () => {
|
|
@@ -78,20 +89,18 @@ test('merge: 无 ancestor → 整分区 conflict(不猜测)', () => {
|
|
|
78
89
|
const rem = settings({ general: { value: { theme: 'light' }, revision: 2 } });
|
|
79
90
|
const plan = merge({ settings: loc as SectionData }, { settings: rem as SectionData });
|
|
80
91
|
assert.equal(plan.sections[0]!.decision, 'conflict');
|
|
92
|
+
assert.equal(plan.sections[0]!.conflicts[0]!.reason, 'no-ancestor');
|
|
81
93
|
});
|
|
82
94
|
|
|
83
|
-
test('merge: JSON
|
|
84
|
-
// 祖先:{ general: {theme:'dark'}, editor: {fontSize:12} }
|
|
95
|
+
test('merge: JSON 非重叠键各自变更 → decision=merge + autoMerged 计数(不再谎报 useRemote)', () => {
|
|
85
96
|
const anc = settings({
|
|
86
97
|
general: { value: { theme: 'dark' }, revision: 1 },
|
|
87
98
|
editor: { value: { fontSize: 12 }, revision: 1 },
|
|
88
99
|
});
|
|
89
|
-
// 本地:改 editor
|
|
90
100
|
const loc = settings({
|
|
91
101
|
general: { value: { theme: 'dark' }, revision: 1 },
|
|
92
102
|
editor: { value: { fontSize: 14 }, revision: 2 },
|
|
93
103
|
});
|
|
94
|
-
// 远端:改 general
|
|
95
104
|
const rem = settings({
|
|
96
105
|
general: { value: { theme: 'light' }, revision: 2 },
|
|
97
106
|
editor: { value: { fontSize: 12 }, revision: 1 },
|
|
@@ -101,29 +110,60 @@ test('merge: JSON 分区键级精细合并 —— 非重叠键各自变更自动
|
|
|
101
110
|
{ settings: rem as SectionData },
|
|
102
111
|
{ settings: anc as SectionData },
|
|
103
112
|
);
|
|
104
|
-
|
|
105
|
-
|
|
113
|
+
const r = plan.sections[0]!;
|
|
114
|
+
assert.equal(r.decision, 'merge');
|
|
115
|
+
assert.equal(r.conflicts.length, 0);
|
|
116
|
+
assert.ok((r.autoMerged ?? 0) > 0, 'autoMerged 必须如实统计并入的变更点');
|
|
117
|
+
const merged = r.merged as SettingsSection;
|
|
106
118
|
assert.deepEqual(merged.namespaces.general!.value, { theme: 'light' });
|
|
107
119
|
assert.deepEqual(merged.namespaces.editor!.value, { fontSize: 14 });
|
|
108
|
-
assert.equal(plan.sections[0]!.conflicts.length, 0);
|
|
109
120
|
});
|
|
110
121
|
|
|
111
|
-
test('merge: JSON
|
|
112
|
-
const anc =
|
|
113
|
-
const loc =
|
|
114
|
-
const rem =
|
|
115
|
-
const plan = merge(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
);
|
|
120
|
-
assert.equal(
|
|
121
|
-
assert.equal(
|
|
122
|
-
assert.equal(plan.sections[0]!.conflicts[0]!.path, 'general');
|
|
123
|
-
assert.equal(plan.sections[0]!.conflicts[0]!.kind, 'key');
|
|
122
|
+
test('merge: 深层嵌套 JSON 合并(不同叶子自动合,未知字段两侧都保留)', () => {
|
|
123
|
+
const anc: SectionData = { version: 1, ui: { theme: { color: 'dark', size: 12 }, extra: { a: 1 } } } as unknown as SectionData;
|
|
124
|
+
const loc: SectionData = { version: 1, ui: { theme: { color: 'blue', size: 12 }, extra: { a: 1 }, localOnly: true } } as unknown as SectionData;
|
|
125
|
+
const rem: SectionData = { version: 1, ui: { theme: { color: 'dark', size: 16 }, extra: { a: 1 }, remoteOnly: true } } as unknown as SectionData;
|
|
126
|
+
const plan = merge({ settings: loc }, { settings: rem }, { settings: anc });
|
|
127
|
+
const r = plan.sections[0]!;
|
|
128
|
+
assert.equal(r.decision, 'merge');
|
|
129
|
+
const merged = r.merged as unknown as { ui: Record<string, unknown> };
|
|
130
|
+
assert.deepEqual(merged.ui['theme'], { color: 'blue', size: 16 });
|
|
131
|
+
assert.equal(merged.ui['localOnly'], true, '本地新增的未知字段不得被静默丢弃');
|
|
132
|
+
assert.equal(merged.ui['remoteOnly'], true, '远端新增的未知字段不得被静默丢弃');
|
|
124
133
|
});
|
|
125
134
|
|
|
126
|
-
test('merge:
|
|
135
|
+
test('merge: 数组双侧都改且不同 → conflict(reason=array),不按索引猜', () => {
|
|
136
|
+
const anc: SectionData = { version: 1, plugins: [{ spec: 'a' }] } as unknown as SectionData;
|
|
137
|
+
const loc: SectionData = { version: 1, plugins: [{ spec: 'a' }, { spec: 'b' }] } as unknown as SectionData;
|
|
138
|
+
const rem: SectionData = { version: 1, plugins: [{ spec: 'c' }] } as unknown as SectionData;
|
|
139
|
+
const plan = merge({ plugins: loc }, { plugins: rem }, { plugins: anc });
|
|
140
|
+
const r = plan.sections[0]!;
|
|
141
|
+
assert.equal(r.decision, 'conflict');
|
|
142
|
+
assert.equal(r.conflicts[0]!.path, 'plugins');
|
|
143
|
+
assert.equal(r.conflicts[0]!.reason, 'array');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('merge: 数组仅一侧改 → 采纳该侧(另一侧键的改动同时保住)', () => {
|
|
147
|
+
const anc: SectionData = { version: 1, plugins: [{ spec: 'a' }], note: 'x' } as unknown as SectionData;
|
|
148
|
+
const loc: SectionData = { version: 1, plugins: [{ spec: 'a' }, { spec: 'b' }], note: 'x' } as unknown as SectionData;
|
|
149
|
+
const rem: SectionData = { version: 1, plugins: [{ spec: 'a' }], note: 'y' } as unknown as SectionData;
|
|
150
|
+
const plan = merge({ plugins: loc }, { plugins: rem }, { plugins: anc });
|
|
151
|
+
const r = plan.sections[0]!;
|
|
152
|
+
assert.equal(r.decision, 'merge');
|
|
153
|
+
const merged = r.merged as unknown as { plugins: unknown[]; note: string };
|
|
154
|
+
assert.equal(merged.plugins.length, 2);
|
|
155
|
+
assert.equal(merged.note, 'y');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('merge: 数组双侧改成相同结果 → 收敛为一份(不算冲突)', () => {
|
|
159
|
+
const anc: SectionData = { version: 1, plugins: [{ spec: 'a' }] } as unknown as SectionData;
|
|
160
|
+
const loc: SectionData = { version: 1, plugins: [{ spec: 'a' }, { spec: 'b' }] } as unknown as SectionData;
|
|
161
|
+
const rem: SectionData = { version: 1, plugins: [{ spec: 'a' }, { spec: 'b' }] } as unknown as SectionData;
|
|
162
|
+
const plan = merge({ plugins: loc }, { plugins: rem }, { plugins: anc });
|
|
163
|
+
assert.equal(plan.sections[0]!.decision, 'skip');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('merge: FilesSection 非重叠文件自动合 → decision=merge', () => {
|
|
127
167
|
const anc = files([['a.md', '# A old\n'], ['b.md', '# B\n']]);
|
|
128
168
|
const loc = files([['a.md', '# A new local\n'], ['b.md', '# B\n']]);
|
|
129
169
|
const rem = files([['a.md', '# A old\n'], ['b.md', '# B remote\n']]);
|
|
@@ -132,44 +172,126 @@ test('merge: FilesSection 文件级精细合并 —— 非重叠文件自动合'
|
|
|
132
172
|
{ skills: rem as SectionData },
|
|
133
173
|
{ skills: anc as SectionData },
|
|
134
174
|
);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
assert.equal(
|
|
139
|
-
assert.equal(
|
|
175
|
+
const r = plan.sections[0]!;
|
|
176
|
+
assert.equal(r.decision, 'merge');
|
|
177
|
+
const merged = r.merged as FilesSection;
|
|
178
|
+
assert.equal(decode(merged, 'a.md'), '# A new local\n');
|
|
179
|
+
assert.equal(decode(merged, 'b.md'), '# B remote\n');
|
|
140
180
|
});
|
|
141
181
|
|
|
142
|
-
test('merge:
|
|
143
|
-
const anc = files([['a.md', '
|
|
144
|
-
const loc = files([['a.md', '
|
|
145
|
-
const rem = files([['a.md', '
|
|
182
|
+
test('merge: 同一文本文件两侧改不同行 → 行级三方合并成功', () => {
|
|
183
|
+
const anc = files([['a.md', 'line1\nline2\nline3\n']]);
|
|
184
|
+
const loc = files([['a.md', 'LINE1\nline2\nline3\n']]);
|
|
185
|
+
const rem = files([['a.md', 'line1\nline2\nLINE3\n']]);
|
|
146
186
|
const plan = merge(
|
|
147
187
|
{ skills: loc as SectionData },
|
|
148
188
|
{ skills: rem as SectionData },
|
|
149
189
|
{ skills: anc as SectionData },
|
|
150
190
|
);
|
|
151
|
-
|
|
152
|
-
assert.equal(
|
|
153
|
-
assert.equal(
|
|
191
|
+
const r = plan.sections[0]!;
|
|
192
|
+
assert.equal(r.decision, 'merge', '不同行的改动必须能自动合并');
|
|
193
|
+
assert.equal(decode(r.merged as FilesSection, 'a.md'), 'LINE1\nline2\nLINE3\n');
|
|
154
194
|
});
|
|
155
195
|
|
|
156
|
-
test('merge:
|
|
157
|
-
const anc =
|
|
196
|
+
test('merge: 同一文本文件两侧改同一行 → 冲突(不静默覆盖任何一侧)', () => {
|
|
197
|
+
const anc = files([['a.md', 'line1\nline2\n']]);
|
|
198
|
+
const loc = files([['a.md', 'line1\nLOCAL\n']]);
|
|
199
|
+
const rem = files([['a.md', 'line1\nREMOTE\n']]);
|
|
158
200
|
const plan = merge(
|
|
159
|
-
{
|
|
160
|
-
{},
|
|
161
|
-
{
|
|
201
|
+
{ skills: loc as SectionData },
|
|
202
|
+
{ skills: rem as SectionData },
|
|
203
|
+
{ skills: anc as SectionData },
|
|
162
204
|
);
|
|
205
|
+
const r = plan.sections[0]!;
|
|
206
|
+
assert.equal(r.decision, 'conflict');
|
|
207
|
+
assert.equal(r.conflicts[0]!.kind, 'file');
|
|
208
|
+
assert.equal(r.conflicts[0]!.path, 'a.md');
|
|
209
|
+
assert.equal(r.conflicts[0]!.reason, 'both-modified');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('merge: 二进制文件两侧都改 → 冲突 reason=binary(不做文本合并)', () => {
|
|
213
|
+
const anc = binFiles([['blob.bin', new Uint8Array([0, 1, 2])]]);
|
|
214
|
+
const loc = binFiles([['blob.bin', new Uint8Array([0, 9, 2])]]);
|
|
215
|
+
const rem = binFiles([['blob.bin', new Uint8Array([0, 7, 2])]]);
|
|
216
|
+
const plan = merge(
|
|
217
|
+
{ skills: loc as SectionData },
|
|
218
|
+
{ skills: rem as SectionData },
|
|
219
|
+
{ skills: anc as SectionData },
|
|
220
|
+
);
|
|
221
|
+
const r = plan.sections[0]!;
|
|
222
|
+
assert.equal(r.decision, 'conflict');
|
|
223
|
+
assert.equal(r.conflicts[0]!.reason, 'binary');
|
|
224
|
+
assert.equal(r.conflicts[0]!.autoMergeAttempted, true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('merge: 文件级 本地改 / 远端删 → modify-delete 冲突(不直接删数据)', () => {
|
|
228
|
+
const anc = files([['a.md', '# A\n'], ['b.md', '# B\n']]);
|
|
229
|
+
const loc = files([['a.md', '# A local edit\n'], ['b.md', '# B\n']]);
|
|
230
|
+
const rem = files([['b.md', '# B\n']]);
|
|
231
|
+
const plan = merge(
|
|
232
|
+
{ skills: loc as SectionData },
|
|
233
|
+
{ skills: rem as SectionData },
|
|
234
|
+
{ skills: anc as SectionData },
|
|
235
|
+
);
|
|
236
|
+
const r = plan.sections[0]!;
|
|
237
|
+
assert.equal(r.decision, 'conflict');
|
|
238
|
+
assert.equal(r.conflicts[0]!.path, 'a.md');
|
|
239
|
+
assert.equal(r.conflicts[0]!.reason, 'modify-delete');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('merge: 文件级 本地删 / 远端改 → delete-modify 冲突', () => {
|
|
243
|
+
const anc = files([['a.md', '# A\n'], ['b.md', '# B\n']]);
|
|
244
|
+
const loc = files([['b.md', '# B\n']]);
|
|
245
|
+
const rem = files([['a.md', '# A remote edit\n'], ['b.md', '# B\n']]);
|
|
246
|
+
const plan = merge(
|
|
247
|
+
{ skills: loc as SectionData },
|
|
248
|
+
{ skills: rem as SectionData },
|
|
249
|
+
{ skills: anc as SectionData },
|
|
250
|
+
);
|
|
251
|
+
const r = plan.sections[0]!;
|
|
252
|
+
assert.equal(r.decision, 'conflict');
|
|
253
|
+
assert.equal(r.conflicts[0]!.path, 'a.md');
|
|
254
|
+
assert.equal(r.conflicts[0]!.reason, 'delete-modify');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('merge: 文件级 本地删 / 远端未改 → 跟随删除(无冲突)', () => {
|
|
258
|
+
const anc = files([['a.md', '# A\n'], ['b.md', '# B\n']]);
|
|
259
|
+
const loc = files([['b.md', '# B\n']]);
|
|
260
|
+
const rem = files([['a.md', '# A\n'], ['b.md', '# B changed\n']]);
|
|
261
|
+
const plan = merge(
|
|
262
|
+
{ skills: loc as SectionData },
|
|
263
|
+
{ skills: rem as SectionData },
|
|
264
|
+
{ skills: anc as SectionData },
|
|
265
|
+
);
|
|
266
|
+
const r = plan.sections[0]!;
|
|
267
|
+
assert.equal(r.decision, 'merge');
|
|
268
|
+
const merged = r.merged as FilesSection;
|
|
269
|
+
assert.equal(decode(merged, 'a.md'), undefined, '本地已删且远端未改 → 跟随删除');
|
|
270
|
+
assert.equal(decode(merged, 'b.md'), '# B changed\n');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('merge: 远端缺 + 本地等于祖先 → skip', () => {
|
|
274
|
+
const anc = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
|
|
275
|
+
const plan = merge({ settings: anc as SectionData }, {}, { settings: anc as SectionData });
|
|
163
276
|
assert.equal(plan.sections[0]!.decision, 'skip');
|
|
164
277
|
});
|
|
165
278
|
|
|
279
|
+
test('merge: 远端缺 + 本地改了 → 冲突且不得丢弃本地改动', () => {
|
|
280
|
+
const anc = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
|
|
281
|
+
const loc = settings({ general: { value: { theme: 'blue' }, revision: 2 } });
|
|
282
|
+
const plan = merge({ settings: loc as SectionData }, {}, { settings: anc as SectionData });
|
|
283
|
+
assert.equal(plan.sections[0]!.decision, 'conflict');
|
|
284
|
+
assert.equal(plan.sections[0]!.conflicts[0]!.reason, 'modify-delete');
|
|
285
|
+
assert.ok(plan.sections[0]!.conflicts[0]!.local !== undefined);
|
|
286
|
+
});
|
|
287
|
+
|
|
166
288
|
test('merge: MergePlan 输出形状符合 contract', () => {
|
|
167
289
|
const anc = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
|
|
168
290
|
const plan = merge({ settings: anc as SectionData }, { settings: anc as SectionData }, { settings: anc as SectionData });
|
|
169
291
|
assert.ok(Array.isArray(plan.sections));
|
|
170
292
|
const r = plan.sections[0]!;
|
|
171
293
|
assert.equal(typeof r.id, 'string');
|
|
172
|
-
assert.ok(['useRemote', 'keepLocal', 'skip', 'conflict'].includes(r.decision));
|
|
294
|
+
assert.ok(['useRemote', 'keepLocal', 'merge', 'skip', 'conflict'].includes(r.decision));
|
|
173
295
|
assert.ok(Array.isArray(r.conflicts));
|
|
174
296
|
});
|
|
175
297
|
|
|
@@ -177,7 +299,6 @@ test('merge: 多分区混合场景(settings skip / providers keepLocal / skill
|
|
|
177
299
|
const sA = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
|
|
178
300
|
const pA: SectionData = { version: 1, providers: { openai: { route: 'https://api.openai.com/v1' } } };
|
|
179
301
|
const fA = files([['a.md', '# A\n']]);
|
|
180
|
-
|
|
181
302
|
const plan = merge(
|
|
182
303
|
{
|
|
183
304
|
settings: sA as SectionData,
|
|
@@ -197,8 +318,20 @@ test('merge: 多分区混合场景(settings skip / providers keepLocal / skill
|
|
|
197
318
|
);
|
|
198
319
|
const byId = new Map(plan.sections.map((s) => [s.id, s]));
|
|
199
320
|
assert.equal(byId.get('settings')!.decision, 'skip');
|
|
200
|
-
// providers:本地改了 route、远端 == 祖先 → keepLocal
|
|
201
321
|
assert.equal(byId.get('providers')!.decision, 'keepLocal');
|
|
202
|
-
// skills:本地 == 祖先、远端新增 b.md → useRemote
|
|
203
322
|
assert.equal(byId.get('skills')!.decision, 'useRemote');
|
|
204
323
|
});
|
|
324
|
+
|
|
325
|
+
test('merge: 类型不一致(一侧文件类 / 一侧 JSON)→ 整分区冲突 reason=section', () => {
|
|
326
|
+
const anc = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
|
|
327
|
+
const loc = files([['a.md', '# A\n']]);
|
|
328
|
+
const rem = settings({ general: { value: { theme: 'light' }, revision: 2 } });
|
|
329
|
+
const plan = merge(
|
|
330
|
+
{ skills: loc as SectionData },
|
|
331
|
+
{ skills: rem as SectionData },
|
|
332
|
+
{ skills: anc as SectionData },
|
|
333
|
+
);
|
|
334
|
+
const r = plan.sections[0]!;
|
|
335
|
+
assert.equal(r.decision, 'conflict');
|
|
336
|
+
assert.equal(r.conflicts[0]!.reason, 'section');
|
|
337
|
+
});
|