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.
Files changed (302) hide show
  1. package/lib/adapters/credentials.d.ts +6 -2
  2. package/lib/adapters/credentials.js +19 -3
  3. package/lib/adapters/file-collection.js +2 -1
  4. package/lib/adapters/mcp.d.ts +48 -2
  5. package/lib/adapters/mcp.js +265 -3
  6. package/lib/adapters/messages.d.ts +3 -0
  7. package/lib/adapters/messages.js +7 -0
  8. package/lib/adapters/plugin-files.js +2 -1
  9. package/lib/adapters/plugins.js +105 -20
  10. package/lib/adapters/pnpm-workspace.d.ts +32 -0
  11. package/lib/adapters/pnpm-workspace.js +113 -2
  12. package/lib/adapters/prompts.d.ts +6 -2
  13. package/lib/adapters/prompts.js +17 -2
  14. package/lib/adapters/providers.d.ts +19 -2
  15. package/lib/adapters/providers.js +59 -3
  16. package/lib/adapters/settings.d.ts +6 -2
  17. package/lib/adapters/settings.js +13 -2
  18. package/lib/adapters/skills.d.ts +25 -1
  19. package/lib/adapters/skills.js +60 -0
  20. package/lib/adapters/ui.d.ts +6 -2
  21. package/lib/adapters/ui.js +13 -2
  22. package/lib/adapters/units.d.ts +18 -0
  23. package/lib/adapters/units.js +39 -0
  24. package/lib/adapters/workspaces.js +2 -1
  25. package/lib/client.d.ts +401 -112
  26. package/lib/client.js +2899 -1525
  27. package/lib/core/backup.js +20 -0
  28. package/lib/core/mcp-connector-store.d.ts +159 -0
  29. package/lib/core/mcp-connector-store.js +445 -0
  30. package/lib/core/messages-core.d.ts +2 -0
  31. package/lib/core/messages-core.js +4 -0
  32. package/lib/core/messages.d.ts +20 -0
  33. package/lib/core/messages.js +44 -0
  34. package/lib/core/phase3-host.js +18 -1
  35. package/lib/core/plugin-cli.d.ts +22 -2
  36. package/lib/core/plugin-cli.js +22 -3
  37. package/lib/core/recovery-orchestrator.d.ts +3 -1
  38. package/lib/core/recovery-orchestrator.js +75 -16
  39. package/lib/core/rollback-activity.d.ts +89 -0
  40. package/lib/core/rollback-activity.js +350 -0
  41. package/lib/core/rollback-ledger.d.ts +0 -9
  42. package/lib/core/rollback-ledger.js +37 -0
  43. package/lib/core/rollback-merge.d.ts +22 -2
  44. package/lib/core/rollback-merge.js +63 -13
  45. package/lib/core/rollback-point.d.ts +93 -0
  46. package/lib/core/rollback-point.js +420 -101
  47. package/lib/core/rollback-undo-journal.d.ts +123 -0
  48. package/lib/core/rollback-undo-journal.js +517 -0
  49. package/lib/core/self-update.js +2 -4
  50. package/lib/core/text-fidelity.d.ts +89 -0
  51. package/lib/core/text-fidelity.js +176 -0
  52. package/lib/core/types.d.ts +12 -3
  53. package/lib/core/update-check.js +2 -4
  54. package/lib/core/validator.d.ts +9 -0
  55. package/lib/core/validator.js +12 -0
  56. package/lib/foreign/bundle.js +42 -3
  57. package/lib/foreign/claude-code.js +358 -26
  58. package/lib/foreign/claude-sessions.d.ts +51 -8
  59. package/lib/foreign/claude-sessions.js +111 -13
  60. package/lib/foreign/read-claude-code.d.ts +33 -4
  61. package/lib/foreign/read-claude-code.js +446 -65
  62. package/lib/foreign/read-claude-plugins.d.ts +36 -0
  63. package/lib/foreign/read-claude-plugins.js +403 -0
  64. package/lib/foreign/registry.js +25 -2
  65. package/lib/foreign/truth-table.js +6 -4
  66. package/lib/foreign/types.d.ts +170 -2
  67. package/lib/index.d.ts +14 -5
  68. package/lib/index.js +60 -16
  69. package/lib/market/git-file-writer.js +7 -1
  70. package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
  71. package/lib/profiles/dsh-profile-launcher.js +58 -1
  72. package/lib/profiles/dsh-profile-shared.d.ts +8 -2
  73. package/lib/profiles/dsh-profile-shared.js +8 -2
  74. package/lib/routes/foreign.js +8 -1
  75. package/lib/routes/index.d.ts +1 -1
  76. package/lib/routes/kit.d.ts +2 -2
  77. package/lib/routes/sync.js +173 -9
  78. package/lib/schema/manifest.d.ts +6 -1
  79. package/lib/schema/manifest.js +55 -0
  80. package/lib/schema/types.d.ts +47 -2
  81. package/lib/sync/ancestor.d.ts +22 -0
  82. package/lib/sync/ancestor.js +66 -2
  83. package/lib/sync/autosync-config.d.ts +73 -0
  84. package/lib/sync/autosync-config.js +89 -0
  85. package/lib/sync/autosync-scheduler.d.ts +80 -6
  86. package/lib/sync/autosync-scheduler.js +263 -40
  87. package/lib/sync/gist/gist-transport.d.ts +18 -1
  88. package/lib/sync/gist/gist-transport.js +42 -3
  89. package/lib/sync/gist/messages.d.ts +2 -0
  90. package/lib/sync/gist/messages.js +5 -0
  91. package/lib/sync/git/git-transport.d.ts +42 -3
  92. package/lib/sync/git/git-transport.js +146 -3
  93. package/lib/sync/merge.d.ts +47 -14
  94. package/lib/sync/merge.js +242 -222
  95. package/lib/sync/messages.d.ts +8 -0
  96. package/lib/sync/messages.js +19 -0
  97. package/lib/sync/risk.d.ts +19 -2
  98. package/lib/sync/risk.js +10 -3
  99. package/lib/sync/s3/messages.d.ts +6 -0
  100. package/lib/sync/s3/messages.js +13 -0
  101. package/lib/sync/s3/s3-transport.d.ts +9 -1
  102. package/lib/sync/s3/s3-transport.js +78 -6
  103. package/lib/sync/s3/sigv4.js +11 -2
  104. package/lib/sync/sync-engine.d.ts +212 -8
  105. package/lib/sync/sync-engine.js +514 -58
  106. package/lib/sync/sync-history.d.ts +11 -0
  107. package/lib/sync/sync-history.js +20 -0
  108. package/lib/sync/sync-state.d.ts +43 -0
  109. package/lib/sync/sync-state.js +44 -0
  110. package/lib/sync/sync-watcher.d.ts +76 -0
  111. package/lib/sync/sync-watcher.js +202 -0
  112. package/lib/sync/text-merge.d.ts +85 -0
  113. package/lib/sync/text-merge.js +513 -0
  114. package/lib/sync/transport.d.ts +48 -0
  115. package/lib/sync/transport.js +12 -0
  116. package/lib/sync/watch-scope.d.ts +38 -0
  117. package/lib/sync/watch-scope.js +95 -0
  118. package/lib/sync/webdav/webdav-transport.d.ts +16 -2
  119. package/lib/sync/webdav/webdav-transport.js +128 -11
  120. package/lib/ui/foreign-view.js +10 -0
  121. package/lib/ui/i18n.d.ts +9 -0
  122. package/lib/ui/i18n.js +22 -0
  123. package/lib/ui/rollback-view.d.ts +12 -0
  124. package/lib/ui/rollback-view.js +2 -0
  125. package/lib/ui/sync-settings-view.d.ts +47 -0
  126. package/lib/ui/sync-settings-view.js +24 -0
  127. package/lib/ui/types.d.ts +21 -0
  128. package/lib/utils/env-lock.d.ts +1 -0
  129. package/lib/utils/env-lock.js +7 -4
  130. package/package.json +6 -6
  131. package/src/adapters/credentials.ts +22 -4
  132. package/src/adapters/file-collection.ts +2 -1
  133. package/src/adapters/mcp.test.ts +235 -1
  134. package/src/adapters/mcp.ts +276 -5
  135. package/src/adapters/messages.ts +7 -0
  136. package/src/adapters/plugin-files.ts +2 -1
  137. package/src/adapters/plugins.test.ts +176 -2
  138. package/src/adapters/plugins.ts +107 -21
  139. package/src/adapters/pnpm-workspace.test.ts +89 -1
  140. package/src/adapters/pnpm-workspace.ts +127 -2
  141. package/src/adapters/prompts.ts +20 -3
  142. package/src/adapters/providers.ts +62 -4
  143. package/src/adapters/settings.ts +16 -3
  144. package/src/adapters/skills.test.ts +141 -0
  145. package/src/adapters/skills.ts +58 -2
  146. package/src/adapters/ui.ts +16 -3
  147. package/src/adapters/units-entries.test.ts +190 -0
  148. package/src/adapters/units.ts +50 -0
  149. package/src/adapters/workspaces.ts +2 -1
  150. package/src/client/about/PluginUpdateDialog.tsx +4 -2
  151. package/src/client/about/ReleaseNotesDialog.tsx +4 -2
  152. package/src/client/chat/RollbackReviewDialog.tsx +3 -2
  153. package/src/client/common/Skeleton.tsx +32 -0
  154. package/src/client/common/api-base.test.ts +36 -0
  155. package/src/client/common/http.test.ts +12 -1
  156. package/src/client/common/http.ts +8 -2
  157. package/src/client/common/routes.ts +46 -3
  158. package/src/client/config-manager.module.css +321 -11
  159. package/src/client/home/HomePanel.tsx +14 -6
  160. package/src/client/import/ForeignImportView.tsx +3 -1
  161. package/src/client/import/import-wizard-steps.tsx +2 -1
  162. package/src/client/library/ArtifactRow.tsx +12 -2
  163. package/src/client/library/LibraryActions.tsx +6 -3
  164. package/src/client/library/LibraryPanel.tsx +21 -3
  165. package/src/client/library/RestorePlanView.tsx +4 -2
  166. package/src/client/locales.ts +3 -0
  167. package/src/client/market/market-api.test.ts +3 -2
  168. package/src/client/recovery/RecoveryPanel.tsx +54 -7
  169. package/src/client/recovery/RollbackCard.tsx +7 -2
  170. package/src/client/recovery/RollbackDiffPane.tsx +3 -2
  171. package/src/client/recovery/recovery-locales.ts +20 -2
  172. package/src/client/recovery/recovery-view.test.ts +39 -1
  173. package/src/client/recovery/recovery-view.ts +23 -1
  174. package/src/client/run-store.test.ts +1 -1
  175. package/src/client/run-store.ts +4 -0
  176. package/src/client/sync/AutosyncCard.tsx +49 -6
  177. package/src/client/sync/MergeModal.tsx +220 -0
  178. package/src/client/sync/MergePreviewView.tsx +306 -0
  179. package/src/client/sync/SyncActivityBar.tsx +31 -0
  180. package/src/client/sync/SyncConfirmView.tsx +2 -1
  181. package/src/client/sync/SyncPanel.tsx +130 -87
  182. package/src/client/sync/SyncProbeRow.tsx +162 -0
  183. package/src/client/sync/SyncSettingsView.test.ts +27 -2
  184. package/src/client/sync/WorkDirCard.tsx +44 -6
  185. package/src/client/sync/history-model.ts +4 -1
  186. package/src/client/sync/merge-view.test.ts +248 -0
  187. package/src/client/sync/merge-view.ts +398 -0
  188. package/src/client/sync/probe-view.test.ts +90 -0
  189. package/src/client/sync/probe-view.ts +63 -0
  190. package/src/client/sync/remote-device-view.test.ts +35 -0
  191. package/src/client/sync/remote-device-view.ts +25 -0
  192. package/src/client/sync/sync-api.test.ts +5 -3
  193. package/src/client/sync/sync-api.ts +212 -2
  194. package/src/client/sync/sync-entry.test.ts +78 -0
  195. package/src/client/sync/sync-entry.ts +53 -0
  196. package/src/client/sync/sync-locales.ts +234 -2
  197. package/src/client/sync/sync-view.test.ts +18 -1
  198. package/src/client/sync/sync-view.ts +20 -0
  199. package/src/core/backup.test.ts +41 -0
  200. package/src/core/backup.ts +20 -0
  201. package/src/core/mcp-connector-store.test.ts +265 -0
  202. package/src/core/mcp-connector-store.ts +516 -0
  203. package/src/core/messages-core.ts +4 -0
  204. package/src/core/messages.ts +46 -0
  205. package/src/core/phase3-host.ts +18 -1
  206. package/src/core/phase3-production-integration.test.ts +4 -0
  207. package/src/core/plugin-cli.test.ts +26 -0
  208. package/src/core/plugin-cli.ts +30 -4
  209. package/src/core/recovery-orchestrator.test.ts +104 -5
  210. package/src/core/recovery-orchestrator.ts +74 -17
  211. package/src/core/rollback-activity.test.ts +311 -0
  212. package/src/core/rollback-activity.ts +367 -0
  213. package/src/core/rollback-cross-session.test.ts +694 -0
  214. package/src/core/rollback-interrupted.test.ts +137 -0
  215. package/src/core/rollback-ledger.ts +32 -0
  216. package/src/core/rollback-merge.test.ts +130 -0
  217. package/src/core/rollback-merge.ts +78 -14
  218. package/src/core/rollback-point.ts +468 -91
  219. package/src/core/rollback-undo-journal.test.ts +508 -0
  220. package/src/core/rollback-undo-journal.ts +571 -0
  221. package/src/core/self-update.ts +2 -5
  222. package/src/core/text-fidelity.test.ts +189 -0
  223. package/src/core/text-fidelity.ts +197 -0
  224. package/src/core/types.ts +11 -3
  225. package/src/core/update-check.ts +2 -5
  226. package/src/core/validator.ts +14 -0
  227. package/src/foreign/bundle-origin.test.ts +265 -0
  228. package/src/foreign/bundle.ts +45 -5
  229. package/src/foreign/claude-code.test.ts +268 -0
  230. package/src/foreign/claude-code.ts +406 -25
  231. package/src/foreign/claude-plugins.test.ts +234 -0
  232. package/src/foreign/claude-sessions.test.ts +141 -0
  233. package/src/foreign/claude-sessions.ts +179 -23
  234. package/src/foreign/file-budget.test.ts +4 -1
  235. package/src/foreign/read-claude-code.test.ts +320 -0
  236. package/src/foreign/read-claude-code.ts +490 -71
  237. package/src/foreign/read-claude-plugins.ts +465 -0
  238. package/src/foreign/registry.ts +25 -2
  239. package/src/foreign/session-ir.test.ts +65 -0
  240. package/src/foreign/truth-table.test.ts +81 -0
  241. package/src/foreign/truth-table.ts +6 -3
  242. package/src/foreign/types.ts +162 -2
  243. package/src/index.facade.test.ts +26 -4
  244. package/src/index.ts +76 -19
  245. package/src/market/git-file-writer.test.ts +4 -0
  246. package/src/market/git-file-writer.ts +7 -1
  247. package/src/profiles/dsh-profile-launcher.test.ts +68 -3
  248. package/src/profiles/dsh-profile-launcher.ts +56 -1
  249. package/src/profiles/dsh-profile-shared.ts +8 -2
  250. package/src/routes/foreign.ts +8 -1
  251. package/src/routes/index.ts +1 -1
  252. package/src/routes/kit.ts +2 -2
  253. package/src/routes/sync.ts +176 -9
  254. package/src/schema/manifest-schema.test.ts +87 -2
  255. package/src/schema/manifest.ts +57 -1
  256. package/src/schema/types.ts +45 -2
  257. package/src/sync/ancestor.ts +76 -2
  258. package/src/sync/autosync-config.test.ts +73 -1
  259. package/src/sync/autosync-config.ts +124 -0
  260. package/src/sync/autosync-risk-tier.test.ts +38 -2
  261. package/src/sync/autosync-scheduler.test.ts +264 -12
  262. package/src/sync/autosync-scheduler.ts +296 -44
  263. package/src/sync/gist/gist-conditional-write.test.ts +203 -0
  264. package/src/sync/gist/gist-transport.ts +53 -5
  265. package/src/sync/gist/messages.ts +5 -0
  266. package/src/sync/git/git-probe.test.ts +80 -0
  267. package/src/sync/git/git-transport.test.ts +185 -0
  268. package/src/sync/git/git-transport.ts +178 -5
  269. package/src/sync/merge.test.ts +188 -55
  270. package/src/sync/merge.ts +282 -224
  271. package/src/sync/messages.ts +19 -0
  272. package/src/sync/risk.ts +29 -5
  273. package/src/sync/s3/messages.ts +13 -0
  274. package/src/sync/s3/s3-conditional-write.test.ts +330 -0
  275. package/src/sync/s3/s3-transport.test.ts +2 -1
  276. package/src/sync/s3/s3-transport.ts +87 -9
  277. package/src/sync/s3/sigv4.test.ts +4 -1
  278. package/src/sync/s3/sigv4.ts +11 -2
  279. package/src/sync/sync-device-identity.test.ts +267 -0
  280. package/src/sync/sync-engine.test.ts +410 -19
  281. package/src/sync/sync-engine.ts +602 -57
  282. package/src/sync/sync-history.test.ts +16 -1
  283. package/src/sync/sync-history.ts +26 -0
  284. package/src/sync/sync-state.test.ts +64 -0
  285. package/src/sync/sync-state.ts +79 -0
  286. package/src/sync/sync-watcher.test.ts +197 -0
  287. package/src/sync/sync-watcher.ts +246 -0
  288. package/src/sync/text-merge.test.ts +358 -0
  289. package/src/sync/text-merge.ts +554 -0
  290. package/src/sync/transport.ts +57 -0
  291. package/src/sync/watch-scope.test.ts +122 -0
  292. package/src/sync/watch-scope.ts +120 -0
  293. package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
  294. package/src/sync/webdav/webdav-transport.ts +137 -14
  295. package/src/ui/foreign-view.test.ts +34 -0
  296. package/src/ui/foreign-view.ts +10 -0
  297. package/src/ui/i18n.ts +22 -0
  298. package/src/ui/rollback-view.ts +14 -0
  299. package/src/ui/sync-settings-view.test.ts +28 -1
  300. package/src/ui/sync-settings-view.ts +43 -0
  301. package/src/ui/types.ts +22 -0
  302. 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
- extraArgs: ['-c', 'credential.helper=', '-c', `credential.helper=store --file=${fileArg}`],
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
  };
@@ -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(([p, c]) => ({ relativePath: p, data: new TextEncoder().encode(c), contentHash: '' })),
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 a = s;
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: loc as SectionData },
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: rem as SectionData },
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: 双侧相对祖先都改且分区 hash 不同 → conflict', () => {
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.ok(plan.sections[0]!.conflicts.length >= 1);
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
- assert.equal(plan.sections[0]!.decision, 'useRemote', '精细合并后无非冲突 → useRemote(承载合并结果)');
105
- const merged = plan.sections[0]!.merged as SettingsSection;
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 分区键级精细合并 —— 双侧均改同一 key 且值不同 → 冲突', () => {
112
- const anc = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
113
- const loc = settings({ general: { value: { theme: 'blue' }, revision: 2 } });
114
- const rem = settings({ general: { value: { theme: 'red' }, revision: 2 } });
115
- const plan = merge(
116
- { settings: loc as SectionData },
117
- { settings: rem as SectionData },
118
- { settings: anc as SectionData },
119
- );
120
- assert.equal(plan.sections[0]!.decision, 'conflict');
121
- assert.equal(plan.sections[0]!.conflicts.length, 1);
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: FilesSection 文件级精细合并 —— 非重叠文件自动合', () => {
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
- assert.equal(plan.sections[0]!.decision, 'useRemote');
136
- const merged = plan.sections[0]!.merged as FilesSection;
137
- const byPath = new Map(merged.files.map((f) => [f.relativePath, new TextDecoder().decode(f.data)]));
138
- assert.equal(byPath.get('a.md'), '# A new local\n');
139
- assert.equal(byPath.get('b.md'), '# B remote\n');
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: FilesSection 文件级精细合并 —— 双侧均改同一文件 → 冲突', () => {
143
- const anc = files([['a.md', '# A old\n']]);
144
- const loc = files([['a.md', '# A local\n']]);
145
- const rem = files([['a.md', '# A remote\n']]);
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
- assert.equal(plan.sections[0]!.decision, 'conflict');
152
- assert.equal(plan.sections[0]!.conflicts[0]!.kind, 'file');
153
- assert.equal(plan.sections[0]!.conflicts[0]!.path, 'a.md');
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: 远端缺 + 本地等于祖先 → skip', () => {
157
- const anc = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
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
- { settings: anc as SectionData },
160
- {}, // 远端无此分区
161
- { settings: anc as SectionData },
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
+ });