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
@@ -0,0 +1,203 @@
1
+ /**
2
+ * GitHub Gist 通道:本轮借鉴项的**针对性**单测(不重复 gist-transport.test.ts 的既有契约)。
3
+ * - ① **不做条件写**(issue #110,2026-10 实测):`PATCH /gists/{id}` 对任何条件请求头都回 400
4
+ * (`Conditional request headers are not allowed in unsafe requests unless supported by the endpoint`),
5
+ * 与 ETag 强弱无关 ⇒ PATCH 恒不带 if-match(upload 与 delete 两条路径都要钉住);
6
+ * - ② 探针:GET /user 证明令牌有效 → 读 gist → snapshotCount + 非本前缀文件数进 hint(只读,零 PATCH)。
7
+ */
8
+ import test from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+
11
+ import { GistTransport, GistTransportError, GIST_DEFAULT_FILE_PREFIX } from './gist-transport.ts';
12
+ import type { GistRequestFn, GistResponse, GistTransportOptions } from './gist-transport.ts';
13
+ import type { SyncSnapshot } from '../transport.ts';
14
+ import { gistMsg } from './messages.ts';
15
+
16
+ const GIST_ID = 'aa5a315d61ae9438b18d';
17
+ const TOKEN = 'ghp_ExampleTokenValueThatMustNeverLeak123456';
18
+ const API = 'https://api.github.com';
19
+ const GIST_URL = API + '/gists/' + GIST_ID;
20
+ const INDEX_FILE = GIST_DEFAULT_FILE_PREFIX + '-index.json';
21
+ /** 任何条件请求头(出现即说明破坏了 gist 端点的约束) */
22
+ const CONDITIONAL_HEADERS = ['if-match', 'if-none-match', 'if-modified-since', 'if-unmodified-since'];
23
+
24
+ interface GistCall { method: string; url: string; headers: Record<string, string>; body?: string }
25
+
26
+ function res(status: number, bodyText = '', headers?: Record<string, string>): GistResponse {
27
+ return {
28
+ status,
29
+ ok: status >= 200 && status < 300,
30
+ ...(headers !== undefined ? { headers } : {}),
31
+ async text() { return bodyText; },
32
+ };
33
+ }
34
+
35
+ function record(calls: GistCall[], handler: (m: GistCall) => GistResponse): GistRequestFn {
36
+ return async (method, url, opts = {}) => {
37
+ const rec: GistCall = { method, url, headers: opts.headers ?? {}, ...(opts.body !== undefined ? { body: opts.body } : {}) };
38
+ calls.push(rec);
39
+ return handler(rec);
40
+ };
41
+ }
42
+
43
+ function makeOptions(request: GistRequestFn, overrides: Partial<GistTransportOptions> = {}): GistTransportOptions {
44
+ return {
45
+ gistId: GIST_ID,
46
+ credentials: { getToken: async () => TOKEN },
47
+ request,
48
+ ...overrides,
49
+ };
50
+ }
51
+
52
+ function sampleSnapshot(overrides: Partial<SyncSnapshot> = {}): SyncSnapshot {
53
+ return {
54
+ id: 'snap-001',
55
+ createdAt: '2026-10-18T10:00:00.000Z',
56
+ manifest: {
57
+ schemaVersion: 1,
58
+ dshVersion: '1.2.3',
59
+ platform: 'win32',
60
+ sectionIds: ['settings'],
61
+ containsSecrets: false,
62
+ },
63
+ sections: {
64
+ settings: { version: 1, namespaces: { general: { value: { theme: 'dark' }, revision: 1, secrets: [] } } },
65
+ },
66
+ ...overrides,
67
+ };
68
+ }
69
+
70
+ const patches = (calls: GistCall[]): GistCall[] => calls.filter((c) => c.method === 'PATCH');
71
+
72
+ /** 断言:本次 PATCH 一个条件头都没带(回归 issue #110) */
73
+ function assertNoConditionalHeaders(call: GistCall | undefined, hint: string): void {
74
+ assert.ok(call !== undefined, hint + ':必须发生 PATCH');
75
+ const present = CONDITIONAL_HEADERS.filter((h) => call.headers[h] !== undefined);
76
+ assert.deepEqual(present, [], hint + ':gist 端点拒绝任何条件请求头,带了就会 400');
77
+ }
78
+
79
+ /* ---------------- ① 不做条件写 ---------------- */
80
+
81
+ test('不做条件写①:强 ETag 也不得注入 if-match(issue #110 回归,带条件头 GitHub 回 400)', async () => {
82
+ const calls: GistCall[] = [];
83
+ const t = new GistTransport(makeOptions(record(calls, (m) => {
84
+ if (m.method === 'GET' && m.url === GIST_URL) return res(200, JSON.stringify({ files: {} }), { etag: '"strong-etag-1"' });
85
+ if (m.method === 'PATCH' && m.url === GIST_URL) return res(200, '{}');
86
+ return res(500, 'unexpected ' + m.method + ' ' + m.url);
87
+ })));
88
+
89
+ await t.upload(sampleSnapshot());
90
+
91
+ const patched = patches(calls);
92
+ assert.equal(patched.length, 1, '必须发生且只发生一次 PATCH');
93
+ assertNoConditionalHeaders(patched[0], '强 ETag');
94
+ });
95
+
96
+ test('不做条件写①:弱 ETag(W/…)同样不带 if-match,写入照常发生', async () => {
97
+ const calls: GistCall[] = [];
98
+ const t = new GistTransport(makeOptions(record(calls, (m) => {
99
+ if (m.method === 'GET' && m.url === GIST_URL) return res(200, JSON.stringify({ files: {} }), { etag: 'W/"weak-etag-1"' });
100
+ if (m.method === 'PATCH' && m.url === GIST_URL) return res(200, '{}');
101
+ return res(500, 'unexpected ' + m.method + ' ' + m.url);
102
+ })));
103
+
104
+ await t.upload(sampleSnapshot());
105
+
106
+ const patched = patches(calls);
107
+ assert.equal(patched.length, 1, '弱/强 ETag 都不影响写入发生');
108
+ assertNoConditionalHeaders(patched[0], '弱 ETag');
109
+ });
110
+
111
+ test('不做条件写①(删除路径):强 ETag 下 delete 的 PATCH 也不带 if-match', async () => {
112
+ const snapshotFile = GIST_DEFAULT_FILE_PREFIX + '-snap-001.json';
113
+ const meta = {
114
+ id: 'snap-001',
115
+ createdAt: '2026-10-18T09:00:00.000Z',
116
+ sections: {},
117
+ manifest: { schemaVersion: 1, dshVersion: '1', platform: 'win32', sectionIds: [], containsSecrets: false },
118
+ };
119
+ const files = { [INDEX_FILE]: { content: JSON.stringify([meta]) }, [snapshotFile]: { content: '{}' } };
120
+ const calls: GistCall[] = [];
121
+ const t = new GistTransport(makeOptions(record(calls, (m) => {
122
+ if (m.method === 'GET' && m.url === GIST_URL) return res(200, JSON.stringify({ files }), { etag: '"strong-del-1"' });
123
+ if (m.method === 'PATCH' && m.url === GIST_URL) return res(200, '{}');
124
+ return res(500, 'unexpected ' + m.method + ' ' + m.url);
125
+ })));
126
+
127
+ await t.delete('snap-001');
128
+
129
+ const patched = patches(calls);
130
+ assert.equal(patched.length, 1);
131
+ assertNoConditionalHeaders(patched[0], '删除路径');
132
+ });
133
+
134
+ /* ---------------- ② 探针 ---------------- */
135
+
136
+ test('② 探针:gist 里有非本前缀文件 → hint 非空且含前缀与计数;snapshotCount 正确', async () => {
137
+ const calls: GistCall[] = [];
138
+ const meta = {
139
+ id: 'snap-a',
140
+ createdAt: '2026-10-18T08:00:00.000Z',
141
+ sections: {},
142
+ manifest: { schemaVersion: 1, dshVersion: '1', platform: 'win32', sectionIds: [], containsSecrets: false },
143
+ };
144
+ const t = new GistTransport(makeOptions(record(calls, (m) => {
145
+ if (m.method === 'GET' && m.url === API + '/user') return res(200, JSON.stringify({ login: 'alice' }));
146
+ if (m.method === 'GET' && m.url === GIST_URL) {
147
+ return res(200, JSON.stringify({
148
+ files: {
149
+ [INDEX_FILE]: { content: JSON.stringify([meta]) },
150
+ 'notes.md': { content: '别人的文件' },
151
+ },
152
+ }));
153
+ }
154
+ return res(500, 'unexpected ' + m.method + ' ' + m.url);
155
+ })));
156
+
157
+ const result = await t.probe();
158
+
159
+ assert.equal(result.snapshotCount, 1, '索引里 1 份快照');
160
+ assert.equal(result.message, gistMsg('sync.gist.probeOk', { count: '1' }));
161
+ assert.ok(result.hint !== undefined && result.hint !== '', '有非本前缀文件时必须给出 hint');
162
+ assert.equal(result.hint, gistMsg('sync.gist.probeForeignContent', { count: '1', prefix: GIST_DEFAULT_FILE_PREFIX }));
163
+ assert.match(result.hint!, /dsh-sync/, 'hint 必须点明本插件前缀');
164
+ assert.equal(patches(calls).length, 0, '探针只读:绝不发 PATCH');
165
+ });
166
+
167
+ test('② 探针:gist 里全是本前缀文件 → hint 省略(不误报「别人的数据」)', async () => {
168
+ const calls: GistCall[] = [];
169
+ const t = new GistTransport(makeOptions(record(calls, (m) => {
170
+ if (m.method === 'GET' && m.url === API + '/user') return res(200, JSON.stringify({ login: 'alice' }));
171
+ if (m.method === 'GET' && m.url === GIST_URL) {
172
+ return res(200, JSON.stringify({ files: { [INDEX_FILE]: { content: '[]' } } }));
173
+ }
174
+ return res(500, 'unexpected ' + m.method + ' ' + m.url);
175
+ })));
176
+
177
+ const result = await t.probe();
178
+ assert.equal(result.hint, undefined);
179
+ assert.equal(result.snapshotCount, 0);
180
+ assert.equal(result.message, gistMsg('sync.gist.probeOk', { count: '0' }));
181
+ });
182
+
183
+ test('② 探针:GET /user 401/403 → 抛错(kind=auth),绝不谎报令牌有效', async () => {
184
+ for (const status of [401, 403]) {
185
+ const calls: GistCall[] = [];
186
+ const t = new GistTransport(makeOptions(record(calls, (m) => {
187
+ if (m.method === 'GET' && m.url === API + '/user') return res(status, 'bad credentials');
188
+ return res(500, 'unexpected ' + m.method + ' ' + m.url);
189
+ })));
190
+
191
+ await assert.rejects(
192
+ () => t.probe(),
193
+ (err: unknown) => {
194
+ assert.ok(err instanceof GistTransportError);
195
+ assert.equal(err.kind, 'auth');
196
+ assert.equal(err.status, status);
197
+ assert.equal(err.message.includes(TOKEN), false, 'token 绝不进错误消息');
198
+ return true;
199
+ },
200
+ );
201
+ assert.equal(calls.some((c) => c.url === GIST_URL), false, '令牌校验失败后不得继续读 gist');
202
+ }
203
+ });
@@ -33,7 +33,7 @@ import {
33
33
  sectionsEqual, SyncTransportError, withSyncRetry,
34
34
  } from '../transport.ts';
35
35
  import type {
36
- SyncRetryOptions, SyncSnapshot, SyncSnapshotMeta, SyncTransport, SyncTransportErrorOptions,
36
+ SyncProbeResult, SyncRetryOptions, SyncSnapshot, SyncSnapshotMeta, SyncTransport, SyncTransportErrorOptions,
37
37
  } from '../transport.ts';
38
38
  import { gistMsg } from './messages.ts';
39
39
  import { composeMsg } from '../s3/messages.ts';
@@ -55,6 +55,13 @@ const MAX_REDIRECTS = 3;
55
55
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
56
56
  const REDACTED = '[REDACTED]';
57
57
 
58
+ /**
59
+ * **gist 通道不做条件写**(issue #110,2026-10 实测):`PATCH /gists/{id}` 对**任何**条件请求头
60
+ * 都直接回 400 —— `Conditional request headers are not allowed in unsafe requests unless supported
61
+ * by the endpoint`,与 ETag 强弱无关(同一请求去掉 If-Match 即 200)。故本通道的 PATCH 一律不带
62
+ * `If-Match`,也**没有**写前并发保护;要并发保护请用 git / webdav / s3 通道(那里的索引写是条件写)。
63
+ */
64
+
58
65
  /** 请求选项:headers / body / 覆盖默认超时(ms;0 = 不超时) */
59
66
  export interface GistRequestOptions {
60
67
  headers?: Record<string, string>;
@@ -229,7 +236,7 @@ export class GistTransport implements SyncTransport {
229
236
  this.assertSafeId(snapshot.id);
230
237
  const token = await this.tokenOnce();
231
238
  const meta = computeSnapshotMeta(snapshot);
232
- const files = await this.readGistFiles(token);
239
+ const { files } = await this.readGist(token);
233
240
  const idxRaw = await this.readFileText(files, this.indexFileName, token);
234
241
  const idxBefore = idxRaw === null ? [] : this.parseIndex(idxRaw, this.gistUrl(), token);
235
242
  const existing = idxBefore.find((m) => m.id === snapshot.id);
@@ -284,7 +291,7 @@ export class GistTransport implements SyncTransport {
284
291
  async delete(id: string): Promise<void> {
285
292
  this.assertSafeId(id);
286
293
  const token = await this.tokenOnce();
287
- const files = await this.readGistFiles(token);
294
+ const { files } = await this.readGist(token);
288
295
  const idxRaw = await this.readFileText(files, this.indexFileName, token);
289
296
  const idx = idxRaw === null ? [] : this.parseIndex(idxRaw, this.gistUrl(), token);
290
297
  const fileName = this.snapshotFileName(id);
@@ -308,6 +315,37 @@ export class GistTransport implements SyncTransport {
308
315
 
309
316
  /* ---------------- 内部实现 ---------------- */
310
317
 
318
+ /**
319
+ * 连通性探针(cloud-sync 借鉴项 ②③):令牌有效性(GET /user)→ gist 可读 → 快照数 →
320
+ * 归属提示(gist 里已有不属于本前缀的文件时给出 warning 级 hint,绝不自动删别人的文件)。
321
+ * 只读:不打任何 PATCH。
322
+ */
323
+ async probe(): Promise<SyncProbeResult> {
324
+ const token = await this.tokenOnce();
325
+ const userUrl = this.o.apiBaseUrl + '/user';
326
+ const me = await this.send('GET', userUrl, token);
327
+ if (me.status === 401 || me.status === 403) {
328
+ const body = await this.safeText(me);
329
+ throw new GistTransportError(await this.failText('GET', userUrl, me, token, body), this.classify(me.status, body));
330
+ }
331
+ if (!me.ok) {
332
+ const body = await this.safeText(me);
333
+ throw new GistTransportError(await this.failText('GET', userUrl, me, token, body), this.classify(me.status, body));
334
+ }
335
+ const { files } = await this.readGist(token);
336
+ const idxRaw = await this.readFileText(files, this.indexFileName, token);
337
+ const count = idxRaw === null ? 0 : this.parseIndex(idxRaw, this.gistUrl(), token).length;
338
+ const prefix = this.o.filePrefix + '-';
339
+ const foreign = Object.keys(files).filter((name) => !name.startsWith(prefix)).length;
340
+ return {
341
+ message: this.o.msg('sync.gist.probeOk', { count: String(count) }),
342
+ snapshotCount: count,
343
+ ...(foreign > 0
344
+ ? { hint: this.o.msg('sync.gist.probeForeignContent', { count: String(foreign), prefix: this.o.filePrefix }) }
345
+ : {}),
346
+ };
347
+ }
348
+
311
349
  private assertSafeId(id: string): void {
312
350
  if (typeof id !== 'string' || !SAFE_ID_RE.test(id) || RESERVED_IDS.has(id)) {
313
351
  throw new GistTransportError(this.o.msg('sync.gist.invalidSnapshotId', { id: JSON.stringify(id) }));
@@ -336,7 +374,7 @@ export class GistTransport implements SyncTransport {
336
374
  method: string,
337
375
  url: string,
338
376
  token: string,
339
- opts: { body?: string; raw?: boolean } = {},
377
+ opts: { body?: string; raw?: boolean; headers?: Record<string, string> } = {},
340
378
  ): Promise<GistResponse> {
341
379
  const headers: Record<string, string> = opts.raw === true
342
380
  ? { accept: 'application/vnd.github.raw', 'User-Agent': 'DSH-Config-Manager (Gist Client)' }
@@ -345,6 +383,8 @@ export class GistTransport implements SyncTransport {
345
383
  'content-type': 'application/json',
346
384
  'X-GitHub-Api-Version': GITHUB_API_VERSION,
347
385
  ...(token !== '' ? { authorization: 'Bearer ' + token } : {}),
386
+ // 附加请求头(本通道当前不注入条件头:gist 端点拒绝任何条件请求头,见文首说明)
387
+ ...(opts.headers ?? {}),
348
388
  };
349
389
  try {
350
390
  return await this.o.request(method, url, {
@@ -371,6 +411,14 @@ export class GistTransport implements SyncTransport {
371
411
 
372
412
  /** 读 gist(含全部文件条目);404 → 明确「gist 不存在或无权访问」 */
373
413
  private async readGistFiles(token: string): Promise<Record<string, GistFileEntry>> {
414
+ return (await this.readGist(token)).files;
415
+ }
416
+
417
+ /**
418
+ * 读 gist 全量文件。响应 ETag **不用**:gist 的 PATCH 端点不接受任何条件请求头(issue #110 实测),
419
+ * 读到强/弱 ETag 都无处可用,故这里不再解析。
420
+ */
421
+ private async readGist(token: string): Promise<{ files: Record<string, GistFileEntry> }> {
374
422
  const url = this.gistUrl();
375
423
  const res = await this.send('GET', url, token);
376
424
  if (res.status === 404) {
@@ -399,7 +447,7 @@ export class GistTransport implements SyncTransport {
399
447
  { kind: 'protocol', retryable: false },
400
448
  );
401
449
  }
402
- return files as Record<string, GistFileEntry>;
450
+ return { files: files as Record<string, GistFileEntry> };
403
451
  }
404
452
 
405
453
  /**
@@ -24,6 +24,9 @@ export const gistZh = {
24
24
  'sync.gist.timeout': '{method} {url} 请求超时({timeout}ms)',
25
25
  'sync.gist.indexInvalid': '{url} 解析失败或结构非法: {err}',
26
26
  'sync.gist.snapshotInvalid': '快照 {id} 解析失败或结构非法: {err}',
27
+ // ---- cloud-sync 借鉴项 ②③:探针 / 归属提示(①条件写已按 issue #110 移除,见 gist-transport.ts)----
28
+ 'sync.gist.probeOk': '连接正常(Gist 可读写,远端 {count} 份快照)',
29
+ 'sync.gist.probeForeignContent': '该 Gist 里已有 {count} 个不属于本插件的文件:请确认这是你的同步专用 Gist(前缀 {prefix}),否则可能覆盖别人的数据',
27
30
  } as const;
28
31
 
29
32
  export const gistEn: Record<keyof typeof gistZh, string> = {
@@ -43,6 +46,8 @@ export const gistEn: Record<keyof typeof gistZh, string> = {
43
46
  'sync.gist.timeout': '{method} {url} request timed out ({timeout}ms)',
44
47
  'sync.gist.indexInvalid': '{url} failed to parse or is structurally invalid: {err}',
45
48
  'sync.gist.snapshotInvalid': 'Snapshot {id} failed to parse or is structurally invalid: {err}',
49
+ 'sync.gist.probeOk': 'Connection OK (gist readable and writable, {count} remote snapshots)',
50
+ 'sync.gist.probeForeignContent': 'This gist already contains {count} files that are not ours: confirm it is your dedicated sync gist (prefix {prefix}) or you may overwrite someone else’s data',
46
51
  };
47
52
 
48
53
  /** 缺省(zh)Gist 通道翻译器。 */
@@ -0,0 +1,80 @@
1
+ /**
2
+ * git 通道的连通性探针(cloud-sync 借鉴项 ②,补齐四条通道)。
3
+ *
4
+ * 真机背景:git 通道此前没有 probe(),同步页点「测试连接」会得到红色「当前同步通道不支持连通性自检」——
5
+ * 用户看到的是「操作失败」,而实际只是那条通道没实现自检。本文件钉住修复后的行为:
6
+ * - 只读:仅发 ls-remote(不 clone / 不 fetch / 不碰工作副本);
7
+ * - 匿名成功 = 公开仓库可达;匿名失败 + 带凭据成功 = 私有仓库且凭据可用;
8
+ * - 两者都失败 → 抛可操作错误,token 绝不出现在消息里;
9
+ * - 刻意不报 snapshotCount(统计快照要先 fetch 工作副本 = 写本地状态)。
10
+ */
11
+ import test from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+
14
+ import { GitTransport, GitTransportError } from './git-transport.ts';
15
+ import type { GitExecFn, GitExecResult, GitTransportOptions } from './git-transport.ts';
16
+ import { zhMsg } from '../../core/messages.ts';
17
+ import { engineMsg } from '../messages.ts';
18
+
19
+ const TOKEN = 'secret-token-probe-001';
20
+ const REPO = 'https://github.com/example/private-config.git';
21
+
22
+ function execStub(responder: (call: number, args: string[]) => GitExecResult): { fn: GitExecFn; calls: string[][] } {
23
+ const calls: string[][] = [];
24
+ let n = 0;
25
+ const fn: GitExecFn = async (_cmd, args) => {
26
+ n += 1;
27
+ calls.push(args);
28
+ return responder(n, args);
29
+ };
30
+ return { fn, calls };
31
+ }
32
+
33
+ function options(exec: GitExecFn, overrides: Partial<GitTransportOptions> = {}): GitTransportOptions {
34
+ return {
35
+ repoUrl: REPO,
36
+ workDir: 'UNUSED-BY-PROBE',
37
+ credentials: { getToken: async () => TOKEN },
38
+ exec,
39
+ ...overrides,
40
+ };
41
+ }
42
+
43
+ test('git probe:匿名 ls-remote 成功 → 连接正常,且**只**发这一条命令(只读)', async () => {
44
+ const { fn, calls } = execStub(() => ({ stdout: '', stderr: '', code: 0 }));
45
+ const transport = new GitTransport(options(fn));
46
+ const result = await transport.probe();
47
+ assert.equal(result.message, engineMsg('sync.git.probeOk'));
48
+ assert.equal(result.snapshotCount, undefined, '不得报快照数(统计它需要 fetch 工作副本)');
49
+ assert.deepEqual(result.createdCollections ?? [], [], 'git 通道不在远端建目录');
50
+ assert.equal(calls.length, 1, '探针只准发一条 ls-remote');
51
+ assert.deepEqual(calls[0], ['ls-remote', REPO]);
52
+ });
53
+
54
+ test('git probe:匿名被拒、带凭据成功 → 连接正常(私有仓库 + 凭据可用)', async () => {
55
+ const { fn, calls } = execStub((n) => (n === 1
56
+ ? { stdout: '', stderr: 'fatal: could not read Username', code: 128 }
57
+ : { stdout: '', stderr: '', code: 0 }));
58
+ const transport = new GitTransport(options(fn));
59
+ const result = await transport.probe();
60
+ assert.equal(result.message, engineMsg('sync.git.probeOk'));
61
+ assert.equal(calls.length, 2, '匿名 + 带凭据各一次 ls-remote');
62
+ assert.ok(calls.every((args) => args.includes('ls-remote')));
63
+ });
64
+
65
+ test('git probe:两者都失败 → 抛可操作错误,且 **token 不出现**在消息里', async () => {
66
+ const { fn } = execStub(() => ({ stdout: '', stderr: `fatal: Authentication failed for '${TOKEN}'`, code: 128 }));
67
+ const transport = new GitTransport(options(fn));
68
+ await assert.rejects(
69
+ async () => { await transport.probe(); },
70
+ (err: unknown) => {
71
+ assert.ok(err instanceof GitTransportError, '必须是通道错误(供上层分类)');
72
+ const message = (err as Error).message;
73
+ // 文案 = 既有字典键(URL 经通道自带的 mask 处理,不出现原始地址)
74
+ assert.equal(message, zhMsg('sync.git.repoUnreachable', { url: '[REPO_URL]' }));
75
+ assert.ok(!message.includes(TOKEN), 'token 绝不进错误消息');
76
+ assert.ok(!message.includes(REPO), '远端地址按通道口径脱敏');
77
+ return true;
78
+ },
79
+ );
80
+ });
@@ -211,6 +211,39 @@ test('token 不泄漏:credential 文件在 git 调用期间存在、用后删
211
211
  for (const c of calls) for (const arg of c.args) assert.ok(!arg.includes(TEST_TOKEN), `argv 泄漏 token: ${arg}`);
212
212
  });
213
213
 
214
+ test('issue #103:credential.username 必须与 store 文件里的用户名一致(全局 .gitconfig 不得顶掉查找键)', async (t) => {
215
+ // git credential store 以 URL 里的 username 为查找键;只写 store 文件不钉 username 时,
216
+ // 全局 .gitconfig 的 credential.<url>.username(GCM / gh 写入)会把键换成别的用户名 →
217
+ // 查不到口令 → 无 tty 环境直接 fatal。这里把「pin 的值 === store 文件里的用户名」钉成不变量。
218
+ for (const username of [undefined, 'x-access-token']) {
219
+ const dir = await makeTempDir(t);
220
+ const seen: { args: string[]; fileRaw: string }[] = [];
221
+ const exec: GitExecFn = async (_cmd, args) => {
222
+ const helper = args.find((a) => a.startsWith('credential.helper=') && a.includes('store --file='));
223
+ if (helper) {
224
+ const file = helper.slice(helper.indexOf('--file=') + '--file='.length).replace(/^"|"$/g, '');
225
+ seen.push({ args, fileRaw: await fs.readFile(file, 'utf8') });
226
+ }
227
+ return { stdout: '', stderr: '', code: 0 };
228
+ };
229
+ const transport = new GitTransport(makeOptions({
230
+ workDir: dir,
231
+ exec,
232
+ ...(username === undefined ? {} : { credentialUsername: username }),
233
+ }));
234
+ await transport.list();
235
+ assert.ok(seen.length >= 1, '必须发生过带凭据的调用');
236
+ const expected = username ?? 'oauth2';
237
+ for (const call of seen) {
238
+ const pin = call.args.find((a) => a.startsWith('credential.username='));
239
+ assert.equal(pin, `credential.username=${expected}`, '必须把 username 钉进 git 参数(缺省 oauth2,可用 credentialUsername 覆盖)');
240
+ const idx = call.args.indexOf(pin as string);
241
+ assert.equal(call.args[idx - 1], '-c', 'git 只认 `-c key=value` 形态,-c 必须紧邻');
242
+ assert.ok(call.fileRaw.startsWith(`https://${expected}:`), 'store 文件里的用户名必须与 pin 的值同源');
243
+ }
244
+ }
245
+ });
246
+
214
247
  test('upload 契约:写快照目录 + add + commit(add) + push,返回 computeSnapshotMeta', async (t) => {
215
248
  const dir = await makeGitWorkDir(t);
216
249
  const calls: CallRecord[] = [];
@@ -1079,6 +1112,8 @@ test('issue #79 集成:inspectWorkDir + rebuildWorkDir(relink 非破坏 / re
1079
1112
  assert.equal(after.origin, bare);
1080
1113
  assert.equal(after.ahead, 0, 'relink 不丢弃/不产生本地提交');
1081
1114
  assert.equal(after.dirty, false, 'relink 不动工作树');
1115
+ // issue #104 建议 3:detail 自检必须同时数出远端分支上的快照(预警依据)
1116
+ assert.equal(after.remoteSnapshots, 1, 'detail 自检数出远端分支上的 1 个快照');
1082
1117
 
1083
1118
  await transport.rebuildWorkDir('reclone');
1084
1119
  assert.ok((await fs.stat(path.join(workDir, '.git'))).isDirectory(), 'reclone 后重新 clone 出 .git');
@@ -1091,3 +1126,153 @@ test('issue #79 集成:inspectWorkDir + rebuildWorkDir(relink 非破坏 / re
1091
1126
  await assert.rejects(t3.rebuildWorkDir('reclone'), /不是 git 仓库/);
1092
1127
  assert.ok(await fs.stat(path.join(strayDir, 'stray.txt')), '拒绝时文件必须原样保留');
1093
1128
  });
1129
+ /* ---------------------------------------------------------------- issue #104 采纳远端历史(adopt) */
1130
+
1131
+ /**
1132
+ * 造一个「另一条历史」的同步仓库:同一个插件布局,但初始提交与现有工作副本不相交。
1133
+ * adopt 的判据是「远端顶层只有快照布局」+「合并原始历史」,所以夹具必须是**假远端**(bare 仓)。
1134
+ */
1135
+ async function makeSyncRemote(t: test.TestContext, snapshot: SyncSnapshot & { sections: Record<string, unknown> }): Promise<string> {
1136
+ const bare = await makeBareRepo(t);
1137
+ const workDir = await makeTempDir(t);
1138
+ await new GitTransport({ repoUrl: bare, workDir, credentials: { getToken: async () => TEST_TOKEN } }).upload(snapshot);
1139
+ return bare;
1140
+ }
1141
+
1142
+ test('issue #104 集成:改成另一条历史的同步仓库 → adopt 合并两条历史(本地提交原样保留、之后能继续 push)', async (t) => {
1143
+ const creds = { getToken: async () => TEST_TOKEN };
1144
+ const bareA = await makeBareRepo(t);
1145
+ const workDir = await makeTempDir(t);
1146
+ await new GitTransport({ repoUrl: bareA, workDir, credentials: creds }).upload(sampleSnapshot({ id: 'snap-001' }));
1147
+
1148
+ const bareC = await makeSyncRemote(t, sampleSnapshot({ id: 'snap-other' }));
1149
+ const localHead = (await runRealGit(['rev-parse', 'HEAD'], workDir)).stdout.trim();
1150
+ const transport = new GitTransport({ repoUrl: bareC, workDir, credentials: creds });
1151
+
1152
+ // 先确认这就是 issue #104 的现场:改地址后 pull --ff-only 因两条历史不相交而失败
1153
+ await assert.rejects(transport.list(), (err: unknown) => {
1154
+ assert.ok(err instanceof SyncTransportError);
1155
+ assert.match(err.message, /不是同一条历史/);
1156
+ return true;
1157
+ });
1158
+
1159
+ await transport.rebuildWorkDir('adopt');
1160
+
1161
+ const facts = await transport.inspectWorkDir({ detail: true });
1162
+ assert.equal(facts.match, true, 'adopt 后 origin 跟随当前配置地址');
1163
+ assert.equal(facts.dirty, false, 'adopt 不留下工作树脏改动');
1164
+ // 2 = 本地原提交(此前只推到过旧地址)+ 这次合并提交:两条都还不在当前地址上,merge 之后需要一次 push。
1165
+ // 断言精确值而不是「>0」:若哪天实现偷偷 rebase 或 force-push,这里会先红。
1166
+ assert.equal(facts.ahead, 2, '本地原提交与合并提交都尚未推到当前地址');
1167
+
1168
+ assert.ok(await fs.stat(path.join(workDir, 'snapshots', 'snap-001')), '本地已有快照保留(不是 reclone)');
1169
+ assert.ok(await fs.stat(path.join(workDir, 'snapshots', 'snap-other')), '远端快照被合并进来');
1170
+
1171
+ const merged = await runRealGit(['log', '--merges', '--oneline'], workDir);
1172
+ assert.match(merged.stdout, /Merge/, '走 merge 而不是 rebase ⇒ 历史里能看到合并提交');
1173
+ const kept = await runRealGit(['merge-base', '--is-ancestor', localHead, 'HEAD'], workDir);
1174
+ assert.equal(kept.code, 0, '本地原提交必须原样保留(rebase 会改写 sha,此断言会红)');
1175
+
1176
+ // adopt 之后普通 push 可用:新快照写进当前地址,旧地址收不到
1177
+ await transport.upload(sampleSnapshot({ id: 'snap-003' }));
1178
+ // issue #104 建议 3 的两个数字必须跟着推送一起收敛:领先 0、远端快照 2
1179
+ const pushed = await transport.inspectWorkDir({ detail: true });
1180
+ assert.equal(pushed.ahead, 0, '推送成功后不再领先远端');
1181
+ // 3 = snap-001(本地历史随合并一起推上去)+ snap-other + snap-003:远端快照数是「远端分支上真实有的」
1182
+ assert.equal(pushed.remoteSnapshots, 3, '远端快照数跟着推送更新(snap-001 + snap-other + snap-003)');
1183
+ const logC = await runRealGit(['log', '--oneline', '--all'], bareC);
1184
+ assert.match(logC.stdout, /snap-003/, '合并后下游能继续 push');
1185
+ const logA = await runRealGit(['log', '--oneline', '--all'], bareA);
1186
+ assert.ok(!logA.stdout.includes('snap-003'), '旧地址不得再收到(否则仍在用旧 origin)');
1187
+ });
1188
+
1189
+ test('issue #104 集成:远端顶层不是同步布局 → 拒绝合并,且一个字节历史都不动', async (t) => {
1190
+ const creds = { getToken: async () => TEST_TOKEN };
1191
+ const bareA = await makeBareRepo(t);
1192
+ const workDir = await makeTempDir(t);
1193
+ await new GitTransport({ repoUrl: bareA, workDir, credentials: creds }).upload(sampleSnapshot({ id: 'snap-001' }));
1194
+ const localHead = (await runRealGit(['rev-parse', 'HEAD'], workDir)).stdout.trim();
1195
+ const branch = (await runRealGit(['rev-parse', '--abbrev-ref', 'HEAD'], workDir)).stdout.trim();
1196
+
1197
+ // 一个普通代码仓库(顶层是 README.md):把它的历史合进来 = 把别人的仓库搬进工作副本
1198
+ const bareOther = await makeBareRepo(t);
1199
+ const seed = await makeTempDir(t);
1200
+ await runRealGit(['init'], seed);
1201
+ await runRealGit(['config', 'user.name', 'test'], seed);
1202
+ await runRealGit(['config', 'user.email', 'test@local'], seed);
1203
+ await runRealGit(['checkout', '-B', branch], seed);
1204
+ await fs.writeFile(path.join(seed, 'README.md'), '# notes\n');
1205
+ await runRealGit(['add', '.'], seed);
1206
+ await runRealGit(['commit', '-m', 'unrelated'], seed);
1207
+ await runRealGit(['remote', 'add', 'origin', bareOther], seed);
1208
+ const pushed = await runRealGit(['push', '-u', 'origin', branch], seed);
1209
+ assert.equal(pushed.code, 0, `push 失败: ${pushed.stderr}`);
1210
+
1211
+ const transport = new GitTransport({ repoUrl: bareOther, workDir, credentials: creds });
1212
+ await assert.rejects(transport.rebuildWorkDir('adopt'), (err: unknown) => {
1213
+ assert.ok(err instanceof SyncTransportError);
1214
+ assert.match(err.message, /README\.md/, '必须点出到底撞见了什么(可操作)');
1215
+ assert.match(err.message, /不是本插件的同步仓库/);
1216
+ assert.equal(err.retryable, false, '重试不会自愈');
1217
+ return true;
1218
+ });
1219
+
1220
+ const head = (await runRealGit(['rev-parse', 'HEAD'], workDir)).stdout.trim();
1221
+ assert.equal(head, localHead, '拒绝时历史一步都不许动');
1222
+ await assert.rejects(fs.stat(path.join(workDir, '.git', 'MERGE_HEAD')), '不得留下半截合并');
1223
+ const status = await runRealGit(['status', '--porcelain'], workDir);
1224
+ assert.equal(status.stdout.trim(), '', '拒绝后工作树保持干净');
1225
+ });
1226
+
1227
+ test('issue #104 集成:工作副本有未提交改动 → 拒绝 adopt(不替用户丢改动)', async (t) => {
1228
+ const creds = { getToken: async () => TEST_TOKEN };
1229
+ const bareA = await makeBareRepo(t);
1230
+ const workDir = await makeTempDir(t);
1231
+ await new GitTransport({ repoUrl: bareA, workDir, credentials: creds }).upload(sampleSnapshot({ id: 'snap-001' }));
1232
+ const localHead = (await runRealGit(['rev-parse', 'HEAD'], workDir)).stdout.trim();
1233
+
1234
+ const bareC = await makeSyncRemote(t, sampleSnapshot({ id: 'snap-other' }));
1235
+ await fs.writeFile(path.join(workDir, 'notes.txt'), 'wip\n');
1236
+
1237
+ const transport = new GitTransport({ repoUrl: bareC, workDir, credentials: creds });
1238
+ await assert.rejects(transport.rebuildWorkDir('adopt'), (err: unknown) => {
1239
+ assert.ok(err instanceof SyncTransportError);
1240
+ assert.match(err.message, /未提交的改动/, '说清为什么拒绝');
1241
+ return true;
1242
+ });
1243
+
1244
+ assert.ok(await fs.stat(path.join(workDir, 'notes.txt')), '用户的文件必须原样保留');
1245
+ const head = (await runRealGit(['rev-parse', 'HEAD'], workDir)).stdout.trim();
1246
+ assert.equal(head, localHead, '被拒绝时不得 merge 任何东西');
1247
+ });
1248
+
1249
+ test('issue #104 集成:两侧同名快照内容不同 → 合并冲突自动撤销,工作副本回到操作前', async (t) => {
1250
+ const creds = { getToken: async () => TEST_TOKEN };
1251
+ const bareA = await makeBareRepo(t);
1252
+ const workDir = await makeTempDir(t);
1253
+ const manifestA = { schemaVersion: 1, dshVersion: '1.0.0', platform: 'win32' as const, sectionIds: ['settings' as SectionId], containsSecrets: false };
1254
+ await new GitTransport({ repoUrl: bareA, workDir, credentials: creds })
1255
+ .upload(sampleSnapshot({ id: 'snap-c', manifest: manifestA }));
1256
+
1257
+ // 同一个快照 id、不同 manifest.json 内容 ⇒ 合并时 add/add 冲突
1258
+ const bareC = await makeSyncRemote(t, sampleSnapshot({
1259
+ id: 'snap-c',
1260
+ manifest: { ...manifestA, dshVersion: '9.9.9' },
1261
+ }));
1262
+ const localHead = (await runRealGit(['rev-parse', 'HEAD'], workDir)).stdout.trim();
1263
+
1264
+ const transport = new GitTransport({ repoUrl: bareC, workDir, credentials: creds });
1265
+ await assert.rejects(transport.rebuildWorkDir('adopt'), (err: unknown) => {
1266
+ assert.ok(err instanceof SyncTransportError);
1267
+ assert.match(err.message, /合并远端 .* 时发生冲突/);
1268
+ assert.match(err.message, /已撤销这次合并/);
1269
+ return true;
1270
+ });
1271
+
1272
+ await assert.rejects(fs.stat(path.join(workDir, '.git', 'MERGE_HEAD')), 'merge --abort 后不得残留 MERGE_HEAD');
1273
+ const status = await runRealGit(['status', '--porcelain'], workDir);
1274
+ assert.equal(status.stdout.trim(), '', '撤销后工作树必须干净(冲突标记不得留在文件里)');
1275
+ const head = (await runRealGit(['rev-parse', 'HEAD'], workDir)).stdout.trim();
1276
+ assert.equal(head, localHead, '撤销后 HEAD 必须回到操作前的提交');
1277
+ });
1278
+