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
@@ -1,5 +1,5 @@
1
1
  import { SyncTransportError } from '../transport.ts';
2
- import type { SyncRetryOptions, SyncSnapshot, SyncSnapshotMeta, SyncTransport, SyncTransportErrorOptions } from '../transport.ts';
2
+ import type { SyncProbeResult, SyncRetryOptions, SyncSnapshot, SyncSnapshotMeta, SyncTransport, SyncTransportErrorOptions } from '../transport.ts';
3
3
  import type { MsgFunc } from '../../core/messages.ts';
4
4
  /** git 命令执行结果(code ≠ 0 = git 命令失败/退出非零) */
5
5
  export interface GitExecResult {
@@ -28,6 +28,14 @@ export interface GitWorkDirFacts {
28
28
  ahead: number | null;
29
29
  /** 工作树是否有未提交改动(读不出 → null) */
30
30
  dirty: boolean | null;
31
+ /**
32
+ * 远端跟踪分支上的快照数(issue #104 建议 3):`snapshots/` 与 `snapshots-encrypted/`
33
+ * 下的直接子项数(目录,或加密通道的 <id>.json 文件)。它和 `ahead` 一起回答
34
+ * 「本地领先 N 个提交、可远端从来没收到过快照吗」—— 那正是 issue #104 里
35
+ * 远端两周一个字节都没收到、用户却以为在正常备份的现场。
36
+ * 只有 detail 自检时给出;无远端跟踪分支 / 读不出 → null。
37
+ */
38
+ remoteSnapshots: number | null;
31
39
  }
32
40
  /** 凭据提供者:token 只从这里读取,绝不在任何地方持久化 */
33
41
  export interface GitCredentialProvider {
@@ -105,6 +113,16 @@ export declare class GitTransport implements SyncTransport {
105
113
  * UI 层可用 isPrivateHint 提示「私有仓库推荐」;公开仓库也可用(内容需自行评估)。
106
114
  */
107
115
  checkIsPrivate(): Promise<boolean>;
116
+ /**
117
+ * 连通性探针(cloud-sync 借鉴项 ②):只读自检「远端可达 + 凭据可用」。
118
+ *
119
+ * 为什么复用 ls-remote:它不需要工作副本、不写任何本地状态,而且正是 clone/fetch 之前的那一步 ——
120
+ * 远端不可达 / 令牌失效 / SSH 主机密钥不信任都会在这里显式失败(checkIsPrivate 抛可操作文案),
121
+ * 因此结论与随后真正同步时的成败**同源**。
122
+ * 刻意**不**统计远端快照数:那要先 fetch 工作副本(对本地有写入),超出「只读自检」的范围 ——
123
+ * 宁可不报,也不报一个会误导的 0。
124
+ */
125
+ probe(): Promise<SyncProbeResult>;
108
126
  private ensureRepo;
109
127
  /**
110
128
  * 让工作副本的 origin 与配置的 repoUrl 保持一致(issue #79)。
@@ -131,14 +149,35 @@ export declare class GitTransport implements SyncTransport {
131
149
  detail?: boolean;
132
150
  }): Promise<GitWorkDirFacts>;
133
151
  /**
134
- * 重建工作副本(issue #79 的自救入口):
152
+ * 重建工作副本(issue #79 / #104 的自救入口):
135
153
  * - `relink`:只把 origin / push URL 重新指向 repoUrl(**非破坏性**:不碰工作树与本地提交);
154
+ * - `adopt`:把远端分支**合并进**现有工作副本(issue #104:换成另一个仓库、或远端历史被重写后,
155
+ * 两条历史不相交,`pull --ff-only` 永远失败;这里合并后本地提交一条都不丢,可 revert);
136
156
  * - `reclone`:删除工作副本目录后重新 clone(**丢弃未上行的本地提交**;调用方必须先让用户确认)。
137
157
  *
138
158
  * 安全边界:`reclone` 只在目录**确实是 git 工作副本**(有 .git)时才删;目录非空且无 .git
139
159
  * 一律拒绝(与 ensureRepo 同一句 workDirNotRepo)—— 绝不删来路不明的目录。
140
160
  */
141
- rebuildWorkDir(mode: 'relink' | 'reclone'): Promise<void>;
161
+ rebuildWorkDir(mode: 'relink' | 'adopt' | 'reclone'): Promise<void>;
162
+ /**
163
+ * 采纳远端历史(issue #104):把远端分支合并进现有工作副本,解开「换了仓库 / 远端历史被重写」
164
+ * 之后 `pull --ff-only` 的死结(真机形态:`+ 8e87c0c...dc69fc7 main (forced update)` 后
165
+ * `fatal: Not possible to fast-forward, aborting.`)。
166
+ *
167
+ * 为什么 merge 而不是 issue 里建议的 `rebase --root --onto`:
168
+ * - rebase 会**改写本地提交**(sha 全变),出问题时无法 revert,中途失败还留下 detached HEAD
169
+ * 与 .git/rebase-merge 目录,状态比失败前更难收拾;
170
+ * - merge 只多一个 merge commit,`git revert -m 1` 可原路退回,冲突时 `merge --abort`
171
+ * 能干净回到起点(真机裸仓验证:冲突 abort 后无 MERGE_HEAD、工作树零改动)。
172
+ *
173
+ * 三条安全边界(宁可不做,也不把别人的仓库合进来):
174
+ * ① 工作树必须干净 —— 有未提交改动时合并会把用户改动卡在冲突里;
175
+ * ② 远端分支顶层只允许快照布局(snapshots / snapshots-encrypted / blobs)—— 否则这是个
176
+ * 「另有用途的仓库」,合并等于把它整个搬进工作副本;
177
+ * ③ 远端必须存在同名分支 —— 远端是空仓库(没有任何分支)时视为没什么可采纳,正常返回
178
+ * (后续 push 会自己建分支);有分支但名字对不上则拒绝猜,把远端分支名列出来。
179
+ */
180
+ private adoptRemoteHistory;
142
181
  /**
143
182
  * pull --ff-only 同步远端;无本地提交(全新仓库)或无 upstream 时静默跳过。
144
183
  * opts.retryable=true(仅 list / download 等**幂等读**路径传):瞬时网络故障走有限指数退避重试。
@@ -55,6 +55,11 @@ const SNAPSHOTS_ENCRYPTED_REL = 'snapshots-encrypted';
55
55
  * 内容没变 = 文件路径已存在 = 一个字都不写、也不进本次 diff。
56
56
  */
57
57
  const BLOBS_REL = 'blobs';
58
+ /**
59
+ * 同步仓库(git 通道)顶层**只可能出现**这三个条目。issue #104 的 `adopt` 模式拿它当安全判据:
60
+ * 远端分支的顶层出现别的名字 = 这不是本插件的同步仓库,合并它等于把别人的仓库内容搬进工作副本。
61
+ */
62
+ const ADOPT_ROOT_ALLOW = new Set([SNAPSHOTS_REL, SNAPSHOTS_ENCRYPTED_REL, BLOBS_REL]);
58
63
  const DEFAULT_AUTHOR = { name: 'DSH Config Sync', email: 'sync@dsh.local' };
59
64
  const DEFAULT_CREDENTIAL_USERNAME = 'oauth2';
60
65
  /** 快照 id 安全字符集:字母数字开头,仅 . _ -;防路径穿越与 commit message 注入 */
@@ -330,6 +335,19 @@ export class GitTransport {
330
335
  }
331
336
  throw new GitTransportError(this.msg('sync.git.repoUnreachable', { url: this.mask(this.o.repoUrl, null) }));
332
337
  }
338
+ /**
339
+ * 连通性探针(cloud-sync 借鉴项 ②):只读自检「远端可达 + 凭据可用」。
340
+ *
341
+ * 为什么复用 ls-remote:它不需要工作副本、不写任何本地状态,而且正是 clone/fetch 之前的那一步 ——
342
+ * 远端不可达 / 令牌失效 / SSH 主机密钥不信任都会在这里显式失败(checkIsPrivate 抛可操作文案),
343
+ * 因此结论与随后真正同步时的成败**同源**。
344
+ * 刻意**不**统计远端快照数:那要先 fetch 工作副本(对本地有写入),超出「只读自检」的范围 ——
345
+ * 宁可不报,也不报一个会误导的 0。
346
+ */
347
+ async probe() {
348
+ await this.checkIsPrivate();
349
+ return { message: this.msg('sync.git.probeOk') };
350
+ }
333
351
  /* ---------------- 内部实现 ---------------- */
334
352
  async ensureRepo() {
335
353
  if (this.repoReady)
@@ -419,7 +437,7 @@ export class GitTransport {
419
437
  async inspectWorkDir(opts = {}) {
420
438
  const fsx = createSnapshotFs();
421
439
  if (!(await fsx.exists(path.join(this.o.workDir, '.git')))) {
422
- return { path: this.o.workDir, exists: false, origin: null, pushUrl: null, match: false, ahead: null, dirty: null };
440
+ return { path: this.o.workDir, exists: false, origin: null, pushUrl: null, match: false, ahead: null, dirty: null, remoteSnapshots: null };
423
441
  }
424
442
  const originRes = await this.runGit(['remote', 'get-url', 'origin'], { allowNonZero: true });
425
443
  const origin = originRes.code === 0 ? originRes.stdout.trim() : '';
@@ -427,6 +445,7 @@ export class GitTransport {
427
445
  const pushRaw = pushRes.code === 0 ? pushRes.stdout.trim() : '';
428
446
  let ahead = null;
429
447
  let dirty = null;
448
+ let remoteSnapshots = null;
430
449
  if (opts.detail === true) {
431
450
  // 未上行的本地提交:HEAD 可达、但任何远端跟踪引用都不可达的提交数(无 upstream 也能算)
432
451
  const aheadRes = await this.runGit(['rev-list', '--count', 'HEAD', '--not', '--remotes'], { allowNonZero: true });
@@ -434,6 +453,28 @@ export class GitTransport {
434
453
  ahead = Number.isFinite(parsed) ? parsed : null;
435
454
  const statusRes = await this.runGit(['status', '--porcelain'], { allowNonZero: true });
436
455
  dirty = statusRes.code === 0 ? statusRes.stdout.trim() !== '' : null;
456
+ // issue #104 建议 3:远端分支上到底有几个快照。只读 refs/remotes/origin/<branch>
457
+ // (上次 fetch / push 的结果),不发网络请求 —— 两条本地只读命令。
458
+ // 注意不能写 `ls-tree origin/<branch> -- snapshots`:带 pathspec 时它只回显目录条目
459
+ // 本身(`snapshots`),数出来永远是 1。必须用 `<ref>:<dir>` 取该树的直接子项。
460
+ const branchRes = await this.runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { allowNonZero: true });
461
+ const branch = branchRes.code === 0 ? branchRes.stdout.trim() : '';
462
+ if (branch !== '' && branch !== 'HEAD') {
463
+ const countTree = async (treeish) => {
464
+ const treeRes = await this.runGit(['ls-tree', '--name-only', treeish], { allowNonZero: true });
465
+ if (treeRes.code !== 0)
466
+ return null;
467
+ return treeRes.stdout
468
+ .split('\n')
469
+ .map((line) => line.trim())
470
+ // 每个快照是一级子项;`.gitkeep` 只是占位,不是快照
471
+ .filter((line) => line !== '' && !line.endsWith(SNAPSHOT_KEEP_FILE)).length;
472
+ };
473
+ const plain = await countTree(`origin/${branch}:${SNAPSHOTS_REL}`);
474
+ const encrypted = await countTree(`origin/${branch}:${SNAPSHOTS_ENCRYPTED_REL}`);
475
+ // 两个目录都不存在 = 远端分支根本没有快照布局(0);分支/引用读不出 = null(不猜)
476
+ remoteSnapshots = plain === null && encrypted === null ? 0 : (plain ?? 0) + (encrypted ?? 0);
477
+ }
437
478
  }
438
479
  return {
439
480
  path: this.o.workDir,
@@ -444,11 +485,14 @@ export class GitTransport {
444
485
  match: origin === this.o.repoUrl && (pushRaw === '' || pushRaw === this.o.repoUrl),
445
486
  ahead,
446
487
  dirty,
488
+ remoteSnapshots,
447
489
  };
448
490
  }
449
491
  /**
450
- * 重建工作副本(issue #79 的自救入口):
492
+ * 重建工作副本(issue #79 / #104 的自救入口):
451
493
  * - `relink`:只把 origin / push URL 重新指向 repoUrl(**非破坏性**:不碰工作树与本地提交);
494
+ * - `adopt`:把远端分支**合并进**现有工作副本(issue #104:换成另一个仓库、或远端历史被重写后,
495
+ * 两条历史不相交,`pull --ff-only` 永远失败;这里合并后本地提交一条都不丢,可 revert);
452
496
  * - `reclone`:删除工作副本目录后重新 clone(**丢弃未上行的本地提交**;调用方必须先让用户确认)。
453
497
  *
454
498
  * 安全边界:`reclone` 只在目录**确实是 git 工作副本**(有 .git)时才删;目录非空且无 .git
@@ -470,9 +514,98 @@ export class GitTransport {
470
514
  await this.ensureRepo(); // 空目录 → 直接 clone
471
515
  return;
472
516
  }
517
+ if (mode === 'adopt') {
518
+ // 已有工作副本但历史与远端不相交:合并远端历史,不删任何本地提交
519
+ await this.adoptRemoteHistory();
520
+ return;
521
+ }
473
522
  await fs.rm(this.o.workDir, { recursive: true, force: true });
474
523
  await this.ensureRepo();
475
524
  }
525
+ /**
526
+ * 采纳远端历史(issue #104):把远端分支合并进现有工作副本,解开「换了仓库 / 远端历史被重写」
527
+ * 之后 `pull --ff-only` 的死结(真机形态:`+ 8e87c0c...dc69fc7 main (forced update)` 后
528
+ * `fatal: Not possible to fast-forward, aborting.`)。
529
+ *
530
+ * 为什么 merge 而不是 issue 里建议的 `rebase --root --onto`:
531
+ * - rebase 会**改写本地提交**(sha 全变),出问题时无法 revert,中途失败还留下 detached HEAD
532
+ * 与 .git/rebase-merge 目录,状态比失败前更难收拾;
533
+ * - merge 只多一个 merge commit,`git revert -m 1` 可原路退回,冲突时 `merge --abort`
534
+ * 能干净回到起点(真机裸仓验证:冲突 abort 后无 MERGE_HEAD、工作树零改动)。
535
+ *
536
+ * 三条安全边界(宁可不做,也不把别人的仓库合进来):
537
+ * ① 工作树必须干净 —— 有未提交改动时合并会把用户改动卡在冲突里;
538
+ * ② 远端分支顶层只允许快照布局(snapshots / snapshots-encrypted / blobs)—— 否则这是个
539
+ * 「另有用途的仓库」,合并等于把它整个搬进工作副本;
540
+ * ③ 远端必须存在同名分支 —— 远端是空仓库(没有任何分支)时视为没什么可采纳,正常返回
541
+ * (后续 push 会自己建分支);有分支但名字对不上则拒绝猜,把远端分支名列出来。
542
+ */
543
+ async adoptRemoteHistory() {
544
+ // ① 未提交改动:不替用户丢改动
545
+ const statusRes = await this.runGit(['status', '--porcelain'], { allowNonZero: true });
546
+ if (statusRes.code !== 0) {
547
+ throw new GitTransportError(this.msg('sync.git.cmdFailed', {
548
+ args: 'status --porcelain',
549
+ code: String(statusRes.code),
550
+ err: this.mask(statusRes.stderr, await this.readTokenOnce()),
551
+ }), { kind: 'unknown', retryable: true });
552
+ }
553
+ if (statusRes.stdout.trim() !== '') {
554
+ throw new GitTransportError(this.msg('sync.git.adoptDirty', { dir: this.o.workDir }), { kind: 'protocol', retryable: false });
555
+ }
556
+ await this.reconcileOrigin();
557
+ const branchRes = await this.runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { allowNonZero: true });
558
+ const branch = branchRes.code === 0 ? branchRes.stdout.trim() : '';
559
+ if (branch === '' || branch === 'HEAD') {
560
+ // detached HEAD:没有分支可挂比 upstream,合并也不会被 push 走 → 拒绝并说明
561
+ throw new GitTransportError(this.msg('sync.git.adoptDetached', { dir: this.o.workDir }), { kind: 'protocol', retryable: false });
562
+ }
563
+ // fetch 是网络操作(幂等读)→ 允许有限指数退避重试
564
+ await withSyncRetry(async () => {
565
+ await this.runGit(['fetch', 'origin'], { withCredential: true });
566
+ }, this.o.retry);
567
+ const remoteRef = `refs/remotes/origin/${branch}`;
568
+ const refRes = await this.runGit(['rev-parse', '--verify', '--quiet', remoteRef], { allowNonZero: true });
569
+ if (refRes.code !== 0) {
570
+ // 远端没有同名分支:用 ls-remote 区分「远端是空仓库」与「分支名不同」
571
+ const headsRes = await this.runGit(['ls-remote', '--heads', 'origin'], { withCredential: true, allowNonZero: true });
572
+ const heads = (headsRes.code === 0 ? headsRes.stdout : '')
573
+ .split('\n')
574
+ .map((line) => line.trim().split('\t')[1] ?? '')
575
+ .filter((ref) => ref.startsWith('refs/heads/'))
576
+ .map((ref) => ref.slice('refs/heads/'.length));
577
+ if (heads.length === 0) {
578
+ this.repoReady = true; // 空仓库:没有可采纳的历史,后续 push 会建分支
579
+ return;
580
+ }
581
+ throw new GitTransportError(this.msg('sync.git.adoptNoRemoteBranch', { branch, heads: heads.slice(0, 3).join(', ') }), { kind: 'protocol', retryable: false });
582
+ }
583
+ // ② 远端顶层只能是快照布局
584
+ const treeRes = await this.runGit(['ls-tree', '--name-only', `origin/${branch}`], { allowNonZero: true });
585
+ if (treeRes.code !== 0) {
586
+ throw new GitTransportError(this.msg('sync.git.cmdFailed', {
587
+ args: `ls-tree --name-only origin/${branch}`,
588
+ code: String(treeRes.code),
589
+ err: this.mask(treeRes.stderr, await this.readTokenOnce()),
590
+ }), { kind: 'unknown', retryable: true });
591
+ }
592
+ const unknownEntries = treeRes.stdout
593
+ .split('\n')
594
+ .map((name) => name.trim().replace(/\/$/, ''))
595
+ .filter((name) => name !== '' && !ADOPT_ROOT_ALLOW.has(name));
596
+ if (unknownEntries.length > 0) {
597
+ throw new GitTransportError(this.msg('sync.git.adoptNotSyncRepo', { entries: unknownEntries.slice(0, 3).join(', ') }), { kind: 'protocol', retryable: false });
598
+ }
599
+ // ③ 合并(不改写任何本地提交);冲突则 abort 回起点再报错
600
+ const mergeRes = await this.runGit(['merge', '--allow-unrelated-histories', '--no-edit', `origin/${branch}`], { allowNonZero: true });
601
+ if (mergeRes.code !== 0) {
602
+ await this.runGit(['merge', '--abort'], { allowNonZero: true });
603
+ throw new GitTransportError(this.msg('sync.git.adoptMergeConflict', { branch }), { kind: 'conflict', retryable: false });
604
+ }
605
+ // 让后续 pull --ff-only 有 upstream 可用(忽略失败:分支可能已被删/重命名)
606
+ await this.runGit(['branch', '--set-upstream-to', `origin/${branch}`, branch], { allowNonZero: true });
607
+ this.repoReady = true;
608
+ }
476
609
  /**
477
610
  * pull --ff-only 同步远端;无本地提交(全新仓库)或无 upstream 时静默跳过。
478
611
  * opts.retryable=true(仅 list / download 等**幂等读**路径传):瞬时网络故障走有限指数退避重试。
@@ -579,7 +712,17 @@ export class GitTransport {
579
712
  const fileArg = quoteGitValue(credFile.replace(/\\/g, '/'));
580
713
  await atomicWriteFile(credFile, `https://${this.o.credentialUsername}:${token}@${host}\n`, { mode: 0o600, symlink: 'reject' });
581
714
  return {
582
- extraArgs: ['-c', 'credential.helper=', '-c', `credential.helper=store --file=${fileArg}`],
715
+ // issue #103:**必须**把用户名也钉进 git 参数。git credential store 用 URL 里的 username
716
+ // 作为查找键;store 文件写的是本插件自己的用户名(默认 oauth2),但 git 会先读全局
717
+ // `.gitconfig` 里的 `credential.<url>.username`(Git Credential Manager / gh CLI 都会写这一行),
718
+ // 用那个用户名去查 store —— 键对不上就查不到口令,无 tty 时退化成
719
+ // `fatal: could not read Password for 'https://<全局用户名>@host'`。
720
+ // 钉住 username 后查找键与 store 文件里的值同源(都来自 credentialUsername)。
721
+ extraArgs: [
722
+ '-c', 'credential.helper=',
723
+ '-c', `credential.helper=store --file=${fileArg}`,
724
+ '-c', `credential.username=${this.o.credentialUsername}`,
725
+ ],
583
726
  token,
584
727
  cleanup: async () => { await fs.rm(tmpDir, { recursive: true, force: true }); },
585
728
  };
@@ -2,37 +2,70 @@
2
2
  * m-sync-flow:三方合并引擎(纯函数)。
3
3
  *
4
4
  * 输入:local / remote / ancestor 各分区的 SectionData + 分区 hash。
5
- * 行为(每分区):
6
- * - localHash == remoteHash → skip(无变化)
7
- * - localHash != remoteHash:
8
- * - localHash == ancestorHash → useRemote(远端独有改动)
9
- * - remoteHash == ancestorHash → keepLocal(本地独有改动)
10
- * - 其余 → 进入精细合并:JSON 分区按 top-level key 合并、FilesSection 按 relativePath 合并;
11
- * 仅双侧均改且值不同的键/文件留为 conflict,其余非重叠变更自动合入。
5
+ * 输出:MergePlan(纯数据,不含副作用),由上层(SyncEngine.merge / previewMerge)交给 UI 决策。
12
6
  *
13
- * 输出:MergePlan(纯数据,不含副作用),由上层(SyncEngine.merge)返回给 UI 决策。
7
+ * 每分区的决策链(顺序即优先级,与 git 的三方合并口径一致):
8
+ * 1. localHash == remoteHash → skip(无变化,绝不产生多余写入)
9
+ * 2. 仅一侧存在该分区 → 单边存在:不构成删除语义(见 §边界),按变化与否取舍
10
+ * 3. ancestor 缺失 → conflict(reason='no-ancestor'):拒绝猜测共同祖先
11
+ * 4. localHash == ancestorHash → useRemote(仅远端改)
12
+ * 5. remoteHash == ancestorHash → keepLocal(仅本地改)
13
+ * 6. 双侧相对祖先都改 → 精细合并:
14
+ * - JSON 分区:递归对象合并(mergeJsonValues),冲突路径精确到叶子('namespaces.general.value.theme')
15
+ * - FilesSection:逐 relativePath 合并,两侧都改的文本文件再走行级三方合并(mergeBytesThreeWay)
16
+ * - 无残留冲突 → decision='merge'(merged + autoMerged 计数)
17
+ * - 有残留冲突 → decision='conflict'(merged 为 undefined:绝不把半成品当结果)
14
18
  *
15
- * 安全约束:ancestor 为 undefined 时退化为两方合并:local != remote 视为整分区 conflict(拒绝猜测共同祖先)。
19
+ * 安全约束(改这里前先读):
20
+ * - 数组**绝不**按索引合并:双侧都改且不逐字相等 → 冲突(reason='array')。语义不明的合并比冲突更危险。
21
+ * - 二进制 / 超大文件不做文本合并(mergeBytesThreeWay 会如实拒绝)→ 保留冲突交用户裁决。
22
+ * - 绝不静默丢弃任一侧的未知字段 / 插件配置 / 用户数据:递归合并只对「双侧都改同一处」报冲突,
23
+ * 单侧新增/修改的键一律保留。
24
+ * - ancestor 缺失时退化为两方:差异一律整分区 conflict(拒绝猜测共同祖先)。
25
+ *
26
+ * 边界(有意如此,勿“修复”):分区在某侧**缺失**不等于「该侧删了这个分区」—— 同步只搬本轮
27
+ * 勾选的分区,可选分区(sessions/mcp)未勾选时为缺失。因此分区级缺失不判删除冲突;
28
+ * 文件级(FilesSection 内的 relativePath)才是真正的删除语义,那里按 modify/delete 判冲突。
16
29
  */
17
30
  import type { SectionData, SectionId } from '../schema/types.ts';
18
- export type MergeDecision = 'useRemote' | 'keepLocal' | 'skip';
19
- /** 分区内精细合并产生的单条冲突(JSON key 或 FilesSection 单文件)。 */
31
+ /**
32
+ * 分区级决胜结果(三方合并,2026-10 收口):
33
+ * - 'skip' :双侧相对祖先都未改(或本就无变化)—— 不产生任何写入;
34
+ * - 'useRemote' :仅远端相对祖先改了(本地未改)→ 远端整分区原值胜出;
35
+ * - 'keepLocal' :仅本地相对祖先改了(远端未改)→ 本地整分区原值胜出;
36
+ * - 'merge' :双侧都改,但**精细合并成功**(无残留冲突)→ merged 为合并结果。
37
+ * 'merge' 绝不等于 'useRemote':把自动合并读成「用了远端」会让 UI/日志谎报数据来源。
38
+ * - 'conflict' :双侧都改且无法安全自动合并 → merged 为 undefined,仅 conflicts 有值。
39
+ */
40
+ export type MergeDecision = 'useRemote' | 'keepLocal' | 'merge' | 'skip';
41
+ /** 冲突成因(可选字段:旧调用方/旧数据没有它,UI 缺省按 'both-modified' 措辞)。 */
42
+ export type MergeConflictReason = 'both-modified' | 'modify-delete' | 'delete-modify' | 'no-ancestor' | 'binary' | 'too-large' | 'overlap' | 'array' | 'format' | 'section';
43
+ /** 分区内精细合并产生的单条冲突(JSON 叶子 / FilesSection 单文件)。 */
20
44
  export interface MergeConflict {
21
- /** 冲突位置:JSON 分区是 top-level key;FilesSection 是 relativePath。 */
45
+ /** 冲突位置:JSON 分区是**点号路径**('a.b.c',整分区为 '$');FilesSection 是 relativePath。 */
22
46
  path: string;
23
47
  kind: 'key' | 'file';
48
+ /** 冲突成因(可选:旧数据没有该字段;UI 缺省按 'both-modified' 措辞) */
49
+ reason?: MergeConflictReason;
50
+ /** 是否已尝试自动合并(true + reason='binary'/'too-large'/'array' = 试过但不安全,故留冲突) */
51
+ autoMergeAttempted?: boolean;
24
52
  local?: unknown;
25
53
  remote?: unknown;
26
54
  ancestor?: unknown;
27
55
  }
28
56
  export interface MergeSectionResult {
29
57
  id: SectionId;
30
- /** 分区级决策;'skip' 时无 merged/conflicts;'conflict' 时 merged 为 null(仅 conflicts)。 */
58
+ /** 分区级决策;'skip' 时无 merged/conflicts;'conflict' 时 merged 为 undefined(仅 conflicts)。 */
31
59
  decision: MergeDecision | 'conflict';
32
- /** 当 decision 为 'useRemote' / 'keepLocal' 时:合并后的整分区数据(= 胜出方原值)。 */
60
+ /** decision 为 'useRemote' / 'keepLocal' / 'merge' 时为整分区数据(原值或合并结果)。 */
33
61
  merged?: SectionData;
34
62
  /** 仅 decision === 'conflict' 时非空:分区级冲突或精细合并后剩余冲突。 */
35
63
  conflicts: MergeConflict[];
64
+ /**
65
+ * 自动合并并入的变更点数量(仅 decision === 'merge' 时有意义;缺省 undefined = 未统计)。
66
+ * UI 用它区分「远端整分区覆盖」与「双方改动都保住了」。
67
+ */
68
+ autoMerged?: number;
36
69
  }
37
70
  export interface MergePlan {
38
71
  /** 每分区的合并结果;仅含 local/remote/ancestor 三者任一出现的分区。 */