dsh-config-manager 0.1.78 → 0.1.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (290) 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 +400 -112
  26. package/lib/client.js +2845 -1524
  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/autosync-config.d.ts +73 -0
  82. package/lib/sync/autosync-config.js +89 -0
  83. package/lib/sync/autosync-scheduler.d.ts +80 -6
  84. package/lib/sync/autosync-scheduler.js +263 -40
  85. package/lib/sync/gist/gist-transport.d.ts +15 -1
  86. package/lib/sync/gist/gist-transport.js +66 -5
  87. package/lib/sync/gist/messages.d.ts +3 -0
  88. package/lib/sync/gist/messages.js +7 -0
  89. package/lib/sync/git/git-transport.d.ts +42 -3
  90. package/lib/sync/git/git-transport.js +146 -3
  91. package/lib/sync/merge.d.ts +47 -14
  92. package/lib/sync/merge.js +242 -222
  93. package/lib/sync/messages.d.ts +8 -0
  94. package/lib/sync/messages.js +19 -0
  95. package/lib/sync/risk.d.ts +19 -2
  96. package/lib/sync/risk.js +10 -3
  97. package/lib/sync/s3/messages.d.ts +6 -0
  98. package/lib/sync/s3/messages.js +13 -0
  99. package/lib/sync/s3/s3-transport.d.ts +9 -1
  100. package/lib/sync/s3/s3-transport.js +78 -6
  101. package/lib/sync/sync-engine.d.ts +193 -4
  102. package/lib/sync/sync-engine.js +450 -37
  103. package/lib/sync/sync-history.d.ts +11 -0
  104. package/lib/sync/sync-history.js +20 -0
  105. package/lib/sync/sync-state.d.ts +43 -0
  106. package/lib/sync/sync-state.js +44 -0
  107. package/lib/sync/sync-watcher.d.ts +76 -0
  108. package/lib/sync/sync-watcher.js +202 -0
  109. package/lib/sync/text-merge.d.ts +85 -0
  110. package/lib/sync/text-merge.js +513 -0
  111. package/lib/sync/transport.d.ts +48 -0
  112. package/lib/sync/transport.js +12 -0
  113. package/lib/sync/watch-scope.d.ts +38 -0
  114. package/lib/sync/watch-scope.js +95 -0
  115. package/lib/sync/webdav/webdav-transport.d.ts +16 -2
  116. package/lib/sync/webdav/webdav-transport.js +128 -11
  117. package/lib/ui/foreign-view.js +10 -0
  118. package/lib/ui/i18n.d.ts +8 -0
  119. package/lib/ui/i18n.js +20 -0
  120. package/lib/ui/rollback-view.d.ts +12 -0
  121. package/lib/ui/rollback-view.js +2 -0
  122. package/lib/ui/sync-settings-view.d.ts +47 -0
  123. package/lib/ui/sync-settings-view.js +24 -0
  124. package/lib/ui/types.d.ts +21 -0
  125. package/lib/utils/env-lock.d.ts +1 -0
  126. package/lib/utils/env-lock.js +7 -4
  127. package/package.json +6 -6
  128. package/src/adapters/credentials.ts +22 -4
  129. package/src/adapters/file-collection.ts +2 -1
  130. package/src/adapters/mcp.test.ts +235 -1
  131. package/src/adapters/mcp.ts +276 -5
  132. package/src/adapters/messages.ts +7 -0
  133. package/src/adapters/plugin-files.ts +2 -1
  134. package/src/adapters/plugins.test.ts +176 -2
  135. package/src/adapters/plugins.ts +107 -21
  136. package/src/adapters/pnpm-workspace.test.ts +89 -1
  137. package/src/adapters/pnpm-workspace.ts +127 -2
  138. package/src/adapters/prompts.ts +20 -3
  139. package/src/adapters/providers.ts +62 -4
  140. package/src/adapters/settings.ts +16 -3
  141. package/src/adapters/skills.test.ts +141 -0
  142. package/src/adapters/skills.ts +58 -2
  143. package/src/adapters/ui.ts +16 -3
  144. package/src/adapters/units-entries.test.ts +190 -0
  145. package/src/adapters/units.ts +50 -0
  146. package/src/adapters/workspaces.ts +2 -1
  147. package/src/client/about/PluginUpdateDialog.tsx +4 -2
  148. package/src/client/about/ReleaseNotesDialog.tsx +4 -2
  149. package/src/client/chat/RollbackReviewDialog.tsx +3 -2
  150. package/src/client/common/Skeleton.tsx +32 -0
  151. package/src/client/common/routes.ts +9 -0
  152. package/src/client/config-manager.module.css +321 -11
  153. package/src/client/home/HomePanel.tsx +14 -6
  154. package/src/client/import/ForeignImportView.tsx +3 -1
  155. package/src/client/import/import-wizard-steps.tsx +2 -1
  156. package/src/client/library/ArtifactRow.tsx +12 -2
  157. package/src/client/library/LibraryActions.tsx +6 -3
  158. package/src/client/library/LibraryPanel.tsx +21 -3
  159. package/src/client/library/RestorePlanView.tsx +4 -2
  160. package/src/client/locales.ts +3 -0
  161. package/src/client/recovery/RecoveryPanel.tsx +54 -7
  162. package/src/client/recovery/RollbackCard.tsx +7 -2
  163. package/src/client/recovery/RollbackDiffPane.tsx +3 -2
  164. package/src/client/recovery/recovery-locales.ts +20 -2
  165. package/src/client/recovery/recovery-view.test.ts +39 -1
  166. package/src/client/recovery/recovery-view.ts +23 -1
  167. package/src/client/run-store.test.ts +1 -1
  168. package/src/client/run-store.ts +4 -0
  169. package/src/client/sync/AutosyncCard.tsx +49 -6
  170. package/src/client/sync/MergeModal.tsx +219 -0
  171. package/src/client/sync/MergePreviewView.tsx +306 -0
  172. package/src/client/sync/SyncActivityBar.tsx +31 -0
  173. package/src/client/sync/SyncConfirmView.tsx +2 -1
  174. package/src/client/sync/SyncPanel.tsx +130 -87
  175. package/src/client/sync/SyncProbeRow.tsx +162 -0
  176. package/src/client/sync/SyncSettingsView.test.ts +27 -2
  177. package/src/client/sync/WorkDirCard.tsx +44 -6
  178. package/src/client/sync/history-model.ts +4 -1
  179. package/src/client/sync/merge-view.test.ts +234 -0
  180. package/src/client/sync/merge-view.ts +371 -0
  181. package/src/client/sync/probe-view.test.ts +90 -0
  182. package/src/client/sync/probe-view.ts +63 -0
  183. package/src/client/sync/remote-device-view.test.ts +35 -0
  184. package/src/client/sync/remote-device-view.ts +25 -0
  185. package/src/client/sync/sync-api.ts +212 -2
  186. package/src/client/sync/sync-entry.test.ts +78 -0
  187. package/src/client/sync/sync-entry.ts +53 -0
  188. package/src/client/sync/sync-locales.ts +234 -2
  189. package/src/client/sync/sync-view.test.ts +18 -1
  190. package/src/client/sync/sync-view.ts +20 -0
  191. package/src/core/backup.test.ts +41 -0
  192. package/src/core/backup.ts +20 -0
  193. package/src/core/mcp-connector-store.test.ts +265 -0
  194. package/src/core/mcp-connector-store.ts +516 -0
  195. package/src/core/messages-core.ts +4 -0
  196. package/src/core/messages.ts +46 -0
  197. package/src/core/phase3-host.ts +18 -1
  198. package/src/core/phase3-production-integration.test.ts +4 -0
  199. package/src/core/plugin-cli.test.ts +26 -0
  200. package/src/core/plugin-cli.ts +30 -4
  201. package/src/core/recovery-orchestrator.test.ts +104 -5
  202. package/src/core/recovery-orchestrator.ts +74 -17
  203. package/src/core/rollback-activity.test.ts +311 -0
  204. package/src/core/rollback-activity.ts +367 -0
  205. package/src/core/rollback-cross-session.test.ts +694 -0
  206. package/src/core/rollback-interrupted.test.ts +137 -0
  207. package/src/core/rollback-ledger.ts +32 -0
  208. package/src/core/rollback-merge.test.ts +130 -0
  209. package/src/core/rollback-merge.ts +78 -14
  210. package/src/core/rollback-point.ts +468 -91
  211. package/src/core/rollback-undo-journal.test.ts +508 -0
  212. package/src/core/rollback-undo-journal.ts +571 -0
  213. package/src/core/self-update.ts +2 -5
  214. package/src/core/text-fidelity.test.ts +189 -0
  215. package/src/core/text-fidelity.ts +197 -0
  216. package/src/core/types.ts +11 -3
  217. package/src/core/update-check.ts +2 -5
  218. package/src/core/validator.ts +14 -0
  219. package/src/foreign/bundle-origin.test.ts +265 -0
  220. package/src/foreign/bundle.ts +45 -5
  221. package/src/foreign/claude-code.test.ts +268 -0
  222. package/src/foreign/claude-code.ts +406 -25
  223. package/src/foreign/claude-plugins.test.ts +234 -0
  224. package/src/foreign/claude-sessions.test.ts +141 -0
  225. package/src/foreign/claude-sessions.ts +179 -23
  226. package/src/foreign/file-budget.test.ts +4 -1
  227. package/src/foreign/read-claude-code.test.ts +320 -0
  228. package/src/foreign/read-claude-code.ts +490 -71
  229. package/src/foreign/read-claude-plugins.ts +465 -0
  230. package/src/foreign/registry.ts +25 -2
  231. package/src/foreign/session-ir.test.ts +65 -0
  232. package/src/foreign/truth-table.test.ts +81 -0
  233. package/src/foreign/truth-table.ts +6 -3
  234. package/src/foreign/types.ts +162 -2
  235. package/src/index.facade.test.ts +26 -4
  236. package/src/index.ts +76 -19
  237. package/src/market/git-file-writer.test.ts +4 -0
  238. package/src/market/git-file-writer.ts +7 -1
  239. package/src/profiles/dsh-profile-launcher.test.ts +68 -3
  240. package/src/profiles/dsh-profile-launcher.ts +56 -1
  241. package/src/profiles/dsh-profile-shared.ts +8 -2
  242. package/src/routes/foreign.ts +8 -1
  243. package/src/routes/index.ts +1 -1
  244. package/src/routes/kit.ts +2 -2
  245. package/src/routes/sync.ts +176 -9
  246. package/src/schema/manifest-schema.test.ts +87 -2
  247. package/src/schema/manifest.ts +57 -1
  248. package/src/schema/types.ts +45 -2
  249. package/src/sync/autosync-config.test.ts +73 -1
  250. package/src/sync/autosync-config.ts +124 -0
  251. package/src/sync/autosync-risk-tier.test.ts +38 -2
  252. package/src/sync/autosync-scheduler.test.ts +264 -12
  253. package/src/sync/autosync-scheduler.ts +296 -44
  254. package/src/sync/gist/gist-conditional-write.test.ts +223 -0
  255. package/src/sync/gist/gist-transport.ts +73 -7
  256. package/src/sync/gist/messages.ts +7 -0
  257. package/src/sync/git/git-probe.test.ts +80 -0
  258. package/src/sync/git/git-transport.test.ts +185 -0
  259. package/src/sync/git/git-transport.ts +178 -5
  260. package/src/sync/merge.test.ts +188 -55
  261. package/src/sync/merge.ts +282 -224
  262. package/src/sync/messages.ts +19 -0
  263. package/src/sync/risk.ts +29 -5
  264. package/src/sync/s3/messages.ts +13 -0
  265. package/src/sync/s3/s3-conditional-write.test.ts +330 -0
  266. package/src/sync/s3/s3-transport.ts +87 -9
  267. package/src/sync/sync-device-identity.test.ts +267 -0
  268. package/src/sync/sync-engine.test.ts +379 -0
  269. package/src/sync/sync-engine.ts +535 -36
  270. package/src/sync/sync-history.test.ts +16 -1
  271. package/src/sync/sync-history.ts +26 -0
  272. package/src/sync/sync-state.test.ts +64 -0
  273. package/src/sync/sync-state.ts +79 -0
  274. package/src/sync/sync-watcher.test.ts +197 -0
  275. package/src/sync/sync-watcher.ts +246 -0
  276. package/src/sync/text-merge.test.ts +358 -0
  277. package/src/sync/text-merge.ts +554 -0
  278. package/src/sync/transport.ts +57 -0
  279. package/src/sync/watch-scope.test.ts +122 -0
  280. package/src/sync/watch-scope.ts +120 -0
  281. package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
  282. package/src/sync/webdav/webdav-transport.ts +137 -14
  283. package/src/ui/foreign-view.test.ts +34 -0
  284. package/src/ui/foreign-view.ts +10 -0
  285. package/src/ui/i18n.ts +20 -0
  286. package/src/ui/rollback-view.ts +14 -0
  287. package/src/ui/sync-settings-view.test.ts +28 -1
  288. package/src/ui/sync-settings-view.ts +43 -0
  289. package/src/ui/types.ts +22 -0
  290. package/src/utils/env-lock.ts +8 -5
@@ -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
+
@@ -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
  };