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,137 @@
1
+ /**
2
+ * 中断的回退(2026-10-18):进程在「回退写到一半」时没了。
3
+ *
4
+ * 纪律:真实临时目录 + 真实读写;**不 mock 文件系统** —— 本组用例要证的正是「磁盘上留下了什么」。
5
+ *
6
+ * 现场形状:<dataDir>/rollback-points/undo/<pid>-<ts36>-<8hex>/ 里有 journal.jsonl(逐条前像 +
7
+ * after 镜像 + 路径),**没有 done**,且目录名里的 pid 已经死了。
8
+ *
9
+ * 三件事各自成文:
10
+ * · I-1:这种目录**不得**被 cleanupOrphanTempFiles 当垃圾删掉(它还可能救命);落 done 之后照旧清。
11
+ * · I-2:恢复只在「当前内容仍等于 after 镜像」时写回 ⇒ 之后被改过的文件**跳过**、绝不覆盖。
12
+ * · I-3:全部条目都自证成立 ⇒ 恢复成功、现场清掉。
13
+ */
14
+ import test from 'node:test'
15
+ import assert from 'node:assert/strict'
16
+ import fs from 'node:fs/promises'
17
+ import os from 'node:os'
18
+ import path from 'node:path'
19
+
20
+ import { RollbackPointEngine } from './rollback-point.ts'
21
+ import { cleanupOrphanTempFiles } from './rollback-ledger.ts'
22
+ import { UNDO_DONE_FILE_NAME, UndoJournalWriter } from './rollback-undo-journal.ts'
23
+ import { sha256Hex } from '../utils/hashing.ts'
24
+
25
+ /** 目录名里的 pid 必须「已死」:取一个超出常见 pid 上限的值(Windows/Linux 都会 ESRCH) */
26
+ const DEAD_PID = 4294967
27
+
28
+ interface Fixture { root: string; dataDir: string; homeDir: string; ws: string; cleanup: () => Promise<void>; undoDir: (name: string) => string }
29
+
30
+ async function makeFixture(): Promise<Fixture> {
31
+ const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-interrupted-')))
32
+ const homeDir = path.join(root, 'home')
33
+ const dataDir = path.join(homeDir, 'dsh-config-manager')
34
+ const ws = path.join(root, 'ws')
35
+ await fs.mkdir(ws, { recursive: true })
36
+ const storeDir = path.join(dataDir, 'rollback-points')
37
+ return {
38
+ root, dataDir, homeDir, ws,
39
+ cleanup: async () => { await fs.rm(root, { recursive: true, force: true }) },
40
+ undoDir: (name: string) => path.join(storeDir, 'undo', name),
41
+ }
42
+ }
43
+
44
+ function makeEngine(fx: Fixture): RollbackPointEngine {
45
+ return new RollbackPointEngine({ dataDir: fx.dataDir, homeDir: fx.homeDir, sessionQuiescentMs: 0 })
46
+ }
47
+
48
+ /** 造一个「回退写到一半」的现场:逐条前像 + after 镜像,**不落 done** */
49
+ async function makeInterrupted(
50
+ fx: Fixture,
51
+ name: string,
52
+ entries: { target: string; before: Uint8Array | null; after: { sha256: string } | { deleted: true } }[],
53
+ ): Promise<string> {
54
+ const dir = fx.undoDir(name)
55
+ await fs.mkdir(dir, { recursive: true })
56
+ const journal = await UndoJournalWriter.create(dir, { now: () => 5_000_000 })
57
+ for (const entry of entries) await journal.record(entry.target, entry.before, entry.after)
58
+ // **有意不调用 finish()** —— 这一行就是「崩在写入中途」
59
+ return dir
60
+ }
61
+
62
+ async function exists(target: string): Promise<boolean> {
63
+ try { await fs.access(target); return true } catch { return false }
64
+ }
65
+
66
+ test('I-1 中断的回退现场:不得被孤儿清理删掉;落 done 之后照旧清', async (t) => {
67
+ const fx = await makeFixture()
68
+ t.after(fx.cleanup)
69
+ const file = path.join(fx.ws, 'a.txt')
70
+ await fs.writeFile(file, 'AFTER')
71
+ const dir = await makeInterrupted(fx, DEAD_PID + '-abc1234-0123abcd', [
72
+ { target: file, before: Buffer.from('BEFORE'), after: { sha256: sha256Hex(Buffer.from('AFTER')) } },
73
+ ])
74
+
75
+ const cleaned = await cleanupOrphanTempFiles(path.join(fx.dataDir, 'rollback-points'))
76
+ assert.equal(await exists(dir), true,
77
+ '有 journal 但没有 done ⇒ 那是「回退写到一半」的现场,绝不能被当垃圾删掉(removed=' + String(cleaned.removed) + ')')
78
+
79
+ const engine = makeEngine(fx)
80
+ const list = await engine.interruptedRollbacks()
81
+ assert.equal(list.length, 1, '必须能被认出来:' + JSON.stringify(list.map((item) => item.dir)))
82
+ assert.equal(list[0]?.entries.length, 1)
83
+ assert.equal(list[0]?.entries[0]?.path, file)
84
+
85
+ // 落 done 之后它就不再是现场了 ⇒ 照旧按残留清掉(判据是 done,不是「永不删除」)
86
+ await fs.writeFile(path.join(dir, UNDO_DONE_FILE_NAME), JSON.stringify({ pid: DEAD_PID, finishedAt: 5_000_000 }))
87
+ const cleaned2 = await cleanupOrphanTempFiles(path.join(fx.dataDir, 'rollback-points'))
88
+ assert.equal(await exists(dir), false, 'done 已落 ⇒ 可以清(removed=' + String(cleaned2.removed) + ')')
89
+ assert.equal((await engine.interruptedRollbacks()).length, 0, '清掉之后不再有现场')
90
+ })
91
+
92
+ test('I-2 恢复只在「当前内容仍等于 after 镜像」时写回:后来被改过的文件一律跳过', async (t) => {
93
+ const fx = await makeFixture()
94
+ t.after(fx.cleanup)
95
+ const mine = path.join(fx.ws, 'mine.txt')
96
+ const touched = path.join(fx.ws, 'touched.txt')
97
+ await fs.writeFile(mine, 'AFTER')
98
+ await fs.writeFile(touched, 'AFTER')
99
+ const dir = await makeInterrupted(fx, DEAD_PID + '-def5678-fedcba98', [
100
+ { target: mine, before: Buffer.from('BEFORE'), after: { sha256: sha256Hex(Buffer.from('AFTER')) } },
101
+ { target: touched, before: Buffer.from('BEFORE'), after: { sha256: sha256Hex(Buffer.from('AFTER')) } },
102
+ ])
103
+ // 崩溃之后、恢复之前:这条被外部改过 ⇒ 它的当前内容不再等于 after 镜像
104
+ await fs.writeFile(touched, 'AFTER-BUT-EDITED-LATER')
105
+
106
+ const engine = makeEngine(fx)
107
+ const result = await engine.recoverInterruptedRollback(dir)
108
+ assert.equal(await fs.readFile(mine, 'utf8'), 'BEFORE', '自证成立的条目必须被写回(退回回退前)')
109
+ assert.equal(await fs.readFile(touched, 'utf8'), 'AFTER-BUT-EDITED-LATER',
110
+ '**绝不覆盖**比我们新的内容:observed=' + JSON.stringify(await fs.readFile(touched, 'utf8')))
111
+ assert.equal(result.restored, 1)
112
+ assert.equal(result.skipped.length, 1, '跳过的条目必须逐条报出来:' + JSON.stringify(result.skipped))
113
+ assert.equal(result.ok, false)
114
+ assert.equal(await exists(dir), true, '没全成功 ⇒ 现场留着(可以再看、再试)')
115
+ })
116
+
117
+ test('I-3 全部条目都自证成立 ⇒ 恢复成功并清掉现场', async (t) => {
118
+ const fx = await makeFixture()
119
+ t.after(fx.cleanup)
120
+ const file = path.join(fx.ws, 'a.txt')
121
+ await fs.writeFile(file, 'AFTER')
122
+ const gone = path.join(fx.ws, 'created-by-rewind.txt')
123
+ await fs.writeFile(gone, 'x') // 回退动作当时**删除**了它 ⇒ after = deleted;恢复时它必须不存在
124
+ await fs.rm(gone)
125
+ const dir = await makeInterrupted(fx, DEAD_PID + '-aaa1111-1111aaaa', [
126
+ { target: file, before: Buffer.from('BEFORE'), after: { sha256: sha256Hex(Buffer.from('AFTER')) } },
127
+ { target: gone, before: Buffer.from('CAME-BACK'), after: { deleted: true } },
128
+ ])
129
+
130
+ const engine = makeEngine(fx)
131
+ const result = await engine.recoverInterruptedRollback(dir)
132
+ assert.equal(result.ok, true, JSON.stringify(result))
133
+ assert.equal(result.restored, 2)
134
+ assert.equal(await fs.readFile(file, 'utf8'), 'BEFORE')
135
+ assert.equal(await fs.readFile(gone, 'utf8'), 'CAME-BACK', '被回退删掉的路径必须被写回')
136
+ assert.equal(await exists(dir), false, '全部成功 ⇒ 现场清掉')
137
+ })
@@ -39,6 +39,7 @@ import { StringDecoder } from 'node:string_decoder'
39
39
  import { atomicWriteFile } from '../utils/atomic-write.ts'
40
40
  import { isRecord } from '../utils/guards.ts'
41
41
  import { sha256Hex } from '../utils/hashing.ts'
42
+ import { UNDO_DONE_FILE_NAME, UNDO_JOURNAL_FILE_NAME } from './rollback-undo-journal.ts'
42
43
  import type {
43
44
  RollbackCode,
44
45
  RollbackPointKind,
@@ -413,6 +414,27 @@ function isProcessAlive(pid: number): boolean {
413
414
  * 3. 该 pid 进程已不存在(崩溃残留)—— 仍然活着的进程可能正在写,绝不抢。
414
415
  * 只扫根目录与它的一级子目录(records/ payloads/ objects/),不递归。
415
416
  */
417
+ /**
418
+ * 这个 undo 目录是不是「**回退写到一半就没了**」的现场(有 journal 索引、但没有 done 标记)。
419
+ *
420
+ * 判据必须与 journal 模块**同一份常量**(import 而不是抄字面量):名字改了这里要跟着改,否则
421
+ * 要么把现场当垃圾删掉(灾难),要么永远不清残留(垃圾堆积)。
422
+ */
423
+ async function hasUnfinishedJournal(dir: string): Promise<boolean> {
424
+ try {
425
+ await fs.access(path.join(dir, UNDO_DONE_FILE_NAME))
426
+ return false
427
+ } catch {
428
+ /* 没有 done:再看有没有 journal 索引 */
429
+ }
430
+ try {
431
+ await fs.access(path.join(dir, UNDO_JOURNAL_FILE_NAME))
432
+ return true
433
+ } catch {
434
+ return false
435
+ }
436
+ }
437
+
416
438
  export async function cleanupOrphanTempFiles(root: string): Promise<RollbackTempCleanupResult> {
417
439
  const out: RollbackTempCleanupResult = { removed: 0, bytes: 0, skipped: 0 }
418
440
  let dirs: string[]
@@ -465,6 +487,16 @@ export async function cleanupOrphanTempFiles(root: string): Promise<RollbackTemp
465
487
  const pid = Number(m[1])
466
488
  if (pid === process.pid || isProcessAlive(pid)) { out.skipped += 1; continue }
467
489
  const target = path.join(undoRoot, name)
490
+ // **例外(2026-10-18)**:有 journal 却没有 done ⇒ 这不是垃圾,是「回退写到一半进程就没了」
491
+ // 的现场。必须留住:既能被 findInterruptedUndos 认出来,也能按 after 镜像安全重放
492
+ //(把工作区退回回退前)。删掉它 = 让半回退的工作区变成无人知晓的残留。
493
+ try {
494
+ if (await hasUnfinishedJournal(target)) { out.skipped += 1; continue }
495
+ } catch {
496
+ // 判据自己读不出来 ⇒ **不碰**(宁可留垃圾,也不删可能还能救命的现场)
497
+ out.skipped += 1
498
+ continue
499
+ }
468
500
  try {
469
501
  const bytes = await dirBytes(target)
470
502
  await fs.rm(target, { recursive: true, force: true })
@@ -8,6 +8,7 @@ import test from 'node:test'
8
8
  import assert from 'node:assert/strict'
9
9
 
10
10
  import { mergeThreeWay } from './rollback-merge.ts'
11
+ import { encodeTextBytes } from './text-fidelity.ts'
11
12
 
12
13
  test('M-1 不同区域:撤销 ours 的改动、保留 theirs 的改动', () => {
13
14
  const ours = ['function main() {', ' const a = 1', ' const b = 2', ' log(a + b)', '}'].join('\n') + '\n'
@@ -131,3 +132,132 @@ test('M-13 插入点落在被删区间**内部** ⇒ 仍然是冲突', () => {
131
132
  assert.equal(r.ok, false)
132
133
  assert.equal(r.reason, 'conflict')
133
134
  })
135
+
136
+ /* ── 2026-10-18 文本保真缺陷(CRLF 被折断 / BOM / 非 UTF-8 写回)的回归 ── */
137
+
138
+ test('M-14 CRLF 文件:撤销本会话改动后**行尾仍是 CRLF**(旧实现会把整份文件折成 LF)', () => {
139
+ const base = 'a\r\nKEEP\r\nDOOMED\r\nx\r\n'
140
+ const ours = 'a\r\nKEEP\r\nx\r\n' // 本会话删掉了 DOOMED
141
+ const theirs = 'a\r\nKEEP\r\nDOOMED\r\nX2\r\n' // 别人改了尾行
142
+ const r = mergeThreeWay(ours, base, theirs)
143
+ assert.equal(r.ok, true, r.reason)
144
+ assert.deepEqual(r.shape, { eol: '\r\n', bom: false })
145
+ assert.equal(r.text, 'a\r\nKEEP\r\nX2\r\n')
146
+ assert.equal(r.eolMixed, false)
147
+ assert.equal(r.eolRewritten, false)
148
+ // 旧实现产出 'a\nKEEP\nX2\n':这里直接查「前面不是 CR 的 LF」——那正是被折过的证据
149
+ assert.equal(/[^\r]\n/.test(r.text), false, '不得出现被折成 LF 的行尾')
150
+ assert.equal(r.revertedLines, 1)
151
+ })
152
+
153
+ test('M-15 CRLF 且磁盘没有尾换行 ⇒ 输出同样不补尾换行(尾换行跟随 theirs)', () => {
154
+ const base = 'a\r\nb\r\nc\r\n'
155
+ const ours = 'a\r\nO1\r\nc\r\n'
156
+ const theirs = 'a\r\nb\r\nC2'
157
+ const r = mergeThreeWay(ours, base, theirs)
158
+ assert.equal(r.ok, true, r.reason)
159
+ assert.deepEqual(r.shape, { eol: '\r\n', bom: false })
160
+ assert.equal(r.text, 'a\r\nO1\r\nC2')
161
+ assert.equal(r.text.endsWith('\n'), false, '磁盘没有尾换行就不擅自补')
162
+ })
163
+
164
+ test('M-16 显式 shape 优先(判据 3):CRLF 输入也能按 LF 输出,并如实标注行尾被改写', () => {
165
+ const base = 'a\r\nb\r\n'
166
+ const ours = 'a\r\nO\r\n'
167
+ const theirs = 'a\r\nb\r\nT\r\n'
168
+ const r = mergeThreeWay(ours, base, theirs, { shape: { eol: '\n', bom: false } })
169
+ assert.equal(r.ok, true, r.reason)
170
+ assert.equal(r.shape.eol, '\n')
171
+ assert.equal(r.eolRewritten, true, '与输入风格不同 ⇒ 必须如实标注')
172
+ assert.equal(r.text, 'a\nO\nT\n')
173
+ })
174
+
175
+ test('M-17 混合行尾:按 theirs 的主导 EOL 输出,并如实标注 eolMixed(判据 5)', () => {
176
+ const base = 'a\r\nb\r\nc\r\n'
177
+ const ours = 'a\r\nO\r\nc\r\n'
178
+ const theirs = 'a\r\nb\r\nT\r\nU\n'
179
+ const r = mergeThreeWay(ours, base, theirs)
180
+ assert.equal(r.ok, true, r.reason)
181
+ assert.equal(r.shape.eol, '\r\n', '2 个 CRLF 对 1 个 LF ⇒ 主导 CRLF')
182
+ assert.equal(r.eolMixed, true, '输入里出现两种行尾 ⇒ 必须标注')
183
+ assert.equal(r.eolRewritten, true)
184
+ assert.equal(r.text, 'a\r\nO\r\nT\r\nU\r\n')
185
+ })
186
+
187
+ test('M-18 BOM:前导 U+FEFF 归 shape 承载(正文不残留),encode 后逐字节复原', () => {
188
+ const base = '\uFEFFa\nb\n'
189
+ const ours = '\uFEFFa\nO\n'
190
+ const theirs = '\uFEFFa\nb\nT\n'
191
+ const r = mergeThreeWay(ours, base, theirs)
192
+ assert.equal(r.ok, true, r.reason)
193
+ assert.equal(r.shape.bom, true, '不得静默丢 BOM')
194
+ assert.equal(r.text.startsWith('\uFEFF'), false, '正文里不残留 BOM 字符(否则 encode 会写出双 BOM)')
195
+ assert.equal(r.text, 'a\nO\nT\n')
196
+ assert.deepEqual(
197
+ [...encodeTextBytes(r.text, r.shape)],
198
+ [...new TextEncoder().encode('\uFEFFa\nO\nT\n')],
199
+ '带 shape 落盘必须逐字节复原(含 BOM)',
200
+ )
201
+ })
202
+
203
+ test('M-19 孤立代理项(无法 UTF-8 无损编码)⇒ reason=non-utf8、ok=false、回落 theirs', () => {
204
+ const r = mergeThreeWay('a\n\uD83D\n', 'a\nb\n', 'a\nb\nT\n')
205
+ assert.equal(r.ok, false)
206
+ assert.equal(r.reason, 'non-utf8')
207
+ assert.equal(r.text, 'a\nb\nT\n', 'ok:false 时 text 一律是 theirs 原文(供比较,绝不写入)')
208
+ })
209
+
210
+ test('M-20 孤立 CR(TextShape 表示不了)⇒ reason=non-utf8,绝不悄悄改成 LF', () => {
211
+ const ours = 'a\rO\r'
212
+ const r = mergeThreeWay(ours, 'a\rb\r', 'a\rb\rT\r')
213
+ assert.equal(r.ok, false)
214
+ assert.equal(r.reason, 'non-utf8')
215
+ assert.equal(r.text, 'a\rb\rT\r')
216
+ })
217
+
218
+ test('M-21 每个分支都带 shape / eolMixed / eolRewritten(调用方无需猜)', () => {
219
+ const binary = mergeThreeWay('x\n', 'a\u0000b\n', 'y\n')
220
+ assert.equal(binary.reason, 'binary')
221
+ assert.deepEqual(binary.shape, { eol: '\n', bom: false })
222
+ const conflict = mergeThreeWay('a\nB-o\nc\n', 'a\nb\nc\n', 'a\nB-t\nc\n')
223
+ assert.equal(conflict.reason, 'conflict')
224
+ assert.deepEqual(conflict.shape, { eol: '\n', bom: false })
225
+ assert.equal(conflict.text, 'a\nB-t\nc\n')
226
+ const clean = mergeThreeWay('a\nb\n', 'a\nb\n', 'a\nb\nT\n')
227
+ assert.equal(clean.reason, 'clean')
228
+ assert.deepEqual(clean.shape, { eol: '\n', bom: false })
229
+ assert.equal(clean.eolMixed, false)
230
+ assert.equal(clean.eolRewritten, false)
231
+ })
232
+
233
+ test('M-22 未传 shape ⇒ 从 theirs 探测形态(不是拿 base / ours 猜)', () => {
234
+ const base = 'a\nb\n'
235
+ const ours = 'a\nO\n'
236
+ const theirs = 'a\r\nb\r\nT\r\n'
237
+ const r = mergeThreeWay(ours, base, theirs)
238
+ assert.equal(r.ok, true, r.reason)
239
+ assert.deepEqual(r.shape, { eol: '\r\n', bom: false })
240
+ assert.equal(r.eolMixed, true, 'base/ours 是 LF、theirs 是 CRLF ⇒ 如实标注风格不一致')
241
+ assert.equal(r.text, 'a\r\nO\r\nT\r\n')
242
+ })
243
+
244
+ test('M-23 空文件 / 空 theirs:不凭空行、不补尾换行、不失败', () => {
245
+ const both = mergeThreeWay('', 'a\n', '')
246
+ assert.equal(both.ok, true)
247
+ assert.equal(both.text, '')
248
+ assert.equal(mergeThreeWay('a\r\nb\r\n', 'a\r\nb\r\n', '').text, '', 'theirs 为空 ⇒ 结果也为空')
249
+ assert.equal(mergeThreeWay('a\nb', 'a\nb\n', 'a\nb\nT\n').ok, true)
250
+ })
251
+
252
+ test('M-24 BOM + CRLF + 中文 端到端:合并结果 encode 后逐字节符合原形态', () => {
253
+ const base = '\uFEFF第一行\r\n第二行\r\n尾行\r\n'
254
+ const ours = '\uFEFF第一行\r\n尾行\r\n'
255
+ const theirs = '\uFEFF第一行\r\n第二行\r\n尾行2\r\n'
256
+ const r = mergeThreeWay(ours, base, theirs)
257
+ assert.equal(r.ok, true, r.reason)
258
+ assert.deepEqual(r.shape, { eol: '\r\n', bom: true })
259
+ assert.deepEqual(
260
+ [...encodeTextBytes(r.text, r.shape)],
261
+ [...new TextEncoder().encode('\uFEFF第一行\r\n尾行2\r\n')],
262
+ )
263
+ })
@@ -22,9 +22,10 @@
22
22
  * 给出**启发式**风险提示(`risks`)—— 它不构成「删除不安全」的证明,也绝不因此声称合并正确。
23
23
  */
24
24
  import { diffLines, looksBinary } from '../utils/line-diff.ts'
25
+ import { isUtf8RoundTrippable, probeTextForm, type TextEol, type TextShape } from './text-fidelity.ts'
25
26
 
26
27
  /** 合并结论(`ok:false` 时调用方必须**零写入**) */
27
- export type ThreeWayMergeReason = 'clean' | 'conflict' | 'degraded' | 'binary'
28
+ export type ThreeWayMergeReason = 'clean' | 'conflict' | 'degraded' | 'binary' | 'non-utf8'
28
29
 
29
30
  export interface ThreeWayMergeResult {
30
31
  /** true = 结果可直接写入;false = 必须放弃写入并如实报告 */
@@ -38,6 +39,15 @@ export interface ThreeWayMergeResult {
38
39
  revertedLines: number
39
40
  /** 依赖风险提示(启发式;空数组 = 未发现明显风险,**不代表安全**) */
40
41
  risks: string[]
42
+ /**
43
+ * 输出的**字节形态**(判据 3/5):落盘必须用 encodeTextBytes(result.text, result.shape) ——
44
+ * result.text 里**不含** BOM,BOM 由 shape.bom 承载。
45
+ */
46
+ shape: TextShape
47
+ /** 输入里出现过**不止一种行尾风格**(单个文件内混合 / 各侧风格不同)⇒ 输出的行尾已被统一,不谎称「原样」 */
48
+ eolMixed: boolean
49
+ /** 输出的行尾**不再逐字节等于输入**(含「显式 shape 与输入风格不同」)⇒ 行尾被改写,绝不静默 */
50
+ eolRewritten: boolean
41
51
  }
42
52
 
43
53
  /** 一处变更块:base 的 `[start, end)` 行被替换成 `lines`(空区间 = 纯插入) */
@@ -131,32 +141,81 @@ function riskTokens(lines: readonly string[]): string[] {
131
141
  return [...seen]
132
142
  }
133
143
 
144
+ /** 合并输入必须是「UTF-8 往返安全 + 行尾可无损表示」的文本(否则绝不合并、绝不写回) */
145
+ function isMergableInput(text: string): boolean {
146
+ return isUtf8RoundTrippable(text) && probeTextForm(text).loneCr === 0
147
+ }
148
+
149
+ /** 剥掉**一个**前导 U+FEFF(BOM 由 shape.bom 承载,正文里不该再残留它) */
150
+ function stripBomPrefix(text: string): string {
151
+ return text.startsWith('\uFEFF') ? text.slice(1) : text
152
+ }
153
+
134
154
  /**
135
155
  * 三方合并(纯函数、无 I/O)。
136
156
  *
157
+ * **文本保真(2026-10-18 缺陷:旧实现用 join('\n') 收尾 ⇒ 把整份 CRLF 文件转成 LF)**:
158
+ * · 形态(EOL / BOM)显式给了 options.shape 就按它;没给就从 **theirs 探测**(磁盘上真实的那一侧),
159
+ * 绝不擅自把 CRLF 改成 LF。落在未变更区域的行取自 base,且只含**行内容**(不含行尾),输出统一按
160
+ * shape.eol 收尾 —— 输入行尾若与输出不一致,用 eolMixed / eolRewritten 如实标注,不假装「原样」。
161
+ * · 输入按契约是**已解码文本**(BOM 已由 decodeTextBytes 剥离);残留的前导 U+FEFF 会被剥掉并记进
162
+ * shape.bom,免得调用方 encode 时写出双 BOM。落盘必须用 encodeTextBytes(result.text, result.shape)。
163
+ * · 输入若含孤立代理项或孤立 CR(TextShape 表达不了的形态)⇒ reason='non-utf8',**零写入**。
164
+ *
137
165
  * 语义:结果是 `ours` 上再应用 `(theirs - base)` —— 撤销「我们从 base 改到 ours 的那部分」,
138
166
  * 同时保留「别人从 base 改到 theirs 的那部分」。
139
167
  */
140
- export function mergeThreeWay(ours: string, base: string, theirs: string): ThreeWayMergeResult {
168
+ export function mergeThreeWay(
169
+ ours: string,
170
+ base: string,
171
+ theirs: string,
172
+ options?: { shape?: TextShape },
173
+ ): ThreeWayMergeResult {
174
+ // 形态:显式优先;未显式给出时从 theirs 探测(主导 EOL + BOM 前缀)。
175
+ const oursForm = probeTextForm(ours)
176
+ const baseForm = probeTextForm(base)
177
+ const theirsForm = probeTextForm(theirs)
178
+ const hadBom = oursForm.bom || baseForm.bom || theirsForm.bom
179
+ const eol: TextEol = options?.shape?.eol ?? theirsForm.eol
180
+ // 显式形态优先;但输入侧确实带过 BOM 时绝不静默丢弃(丢 BOM 属内容改动)
181
+ const shape: TextShape = { eol, bom: options?.shape ? options.shape.bom || hadBom : hadBom }
182
+ const styles = new Set<TextEol>()
183
+ for (const form of [oursForm, baseForm, theirsForm]) {
184
+ for (const style of form.eols) styles.add(style)
185
+ }
186
+ /** 输入里出现过不止一种行尾风格 */
187
+ const eolMixed = styles.size > 1
188
+ /** 输出的行尾不再逐字节等于输入(已被统一) */
189
+ const eolRewritten = eolMixed || (styles.size === 1 && !styles.has(eol))
190
+ // ok:false 时 text 一律是**调用方给的 theirs 原文**(绝不落入半截合并结果)
141
191
  const empty = (reason: ThreeWayMergeReason, conflicts = 0, risks: string[] = []): ThreeWayMergeResult => ({
142
- ok: false, text: theirs, reason, conflicts, revertedLines: 0, risks,
192
+ ok: false, text: theirs, reason, conflicts, revertedLines: 0, risks, shape, eolMixed, eolRewritten,
143
193
  })
144
194
  // 没有基线就退化成「谁的改动都不认识」—— 调用方负责在无 base 时根本不调用本函数
145
195
  if (looksBinary(base) || looksBinary(ours) || looksBinary(theirs)) return empty('binary')
146
- if (ours === base) {
196
+ // 非法 / 无法无损表示的文本:孤立代理项(encode 时会被替换成 U+FFFD)、孤立 CR(行尾表示不了)
197
+ if (![ours, base, theirs].every(isMergableInput)) return empty('non-utf8')
198
+ // 前导 U+FEFF 归 shape.bom 管;正文里剥掉它,免得它被当成「行内容」混进 diff
199
+ const oursText = stripBomPrefix(ours)
200
+ const baseText = stripBomPrefix(base)
201
+ const theirsText = stripBomPrefix(theirs)
202
+ const clean = (text: string): ThreeWayMergeResult => ({
203
+ ok: true, text, reason: 'clean', conflicts: 0, revertedLines: 0, risks: [], shape, eolMixed, eolRewritten,
204
+ })
205
+ if (oursText === baseText) {
147
206
  // 本会话没改过 ⇒ 结论就是「保持别人现在的内容」(不是失败)
148
- return { ok: true, text: theirs, reason: 'clean', conflicts: 0, revertedLines: 0, risks: [] }
207
+ return clean(theirsText)
149
208
  }
150
- if (theirs === base) {
209
+ if (theirsText === baseText) {
151
210
  // 别人没改过 ⇒ 直接回到 ours
152
- return { ok: true, text: ours, reason: 'clean', conflicts: 0, revertedLines: 0, risks: [] }
211
+ return clean(oursText)
153
212
  }
154
- if (ours === theirs) {
213
+ if (oursText === theirsText) {
155
214
  // 双方最终内容一致 ⇒ 谁都不用动
156
- return { ok: true, text: theirs, reason: 'clean', conflicts: 0, revertedLines: 0, risks: [] }
215
+ return clean(theirsText)
157
216
  }
158
- const oursBlocks = changeBlocks(base, ours)
159
- const theirsBlocks = changeBlocks(base, theirs)
217
+ const oursBlocks = changeBlocks(baseText, oursText)
218
+ const theirsBlocks = changeBlocks(baseText, theirsText)
160
219
  if (oursBlocks === null || theirsBlocks === null) return empty('degraded')
161
220
 
162
221
  // 冲突扫描:任一 ours 块与任一 theirs 块触碰且内容不同 ⇒ 冲突
@@ -170,7 +229,7 @@ export function mergeThreeWay(ours: string, base: string, theirs: string): Three
170
229
  }
171
230
  if (conflicts > 0) return empty('conflict', conflicts)
172
231
 
173
- const baseLines = splitLinesOf(base)
232
+ const baseLines = splitLinesOf(baseText)
174
233
  const merged: string[] = []
175
234
  const reverted: string[] = []
176
235
  let cursor = 0
@@ -194,13 +253,18 @@ export function mergeThreeWay(ours: string, base: string, theirs: string): Three
194
253
  cursor = Math.max(cursor, block.end)
195
254
  }
196
255
  for (let i = cursor; i < baseLines.length; i += 1) merged.push(baseLines[i] as string)
256
+ // 行尾统一按 shape.eol 收尾(混合 / 被改写的行尾已由 eolMixed / eolRewritten 如实标注);
197
257
  // 尾换行以**当前磁盘**(theirs)为准:我们是在它的基础上撤销本会话的改动,不擅改它原本的收尾形态
198
- const text = merged.join('\n') + (merged.length > 0 && theirs.endsWith('\n') ? '\n' : '')
258
+ const tail = merged.length > 0 && theirsText.endsWith('\n') ? eol : ''
259
+ const text = merged.join(eol) + tail
199
260
 
200
261
  // 依赖风险(启发式):被撤销的行里出现过、且合并结果里仍能读到的标识符
201
262
  const tokens = riskTokens(reverted)
202
263
  const risks = tokens.filter((token) => new RegExp('\\b' + token + '\\b').test(text)).slice(0, 8)
203
- return { ok: true, text, reason: 'clean', conflicts: 0, revertedLines: reverted.length, risks }
264
+ return {
265
+ ok: true, text, reason: 'clean', conflicts: 0, revertedLines: reverted.length, risks,
266
+ shape, eolMixed, eolRewritten,
267
+ }
204
268
  }
205
269
 
206
270
  /** 与 `splitLines` 同口径的本地折行(避免为了一个函数再引一层依赖) */