@vantran-se/openchamber-web 1.24.4 → 2.0.1

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 (367) hide show
  1. package/README.md +2 -8
  2. package/bin/lib/commands-connect-url.js +3 -8
  3. package/bin/lib/commands-tunnel.js +3 -0
  4. package/dist/assets/{CodeMirrorCommentWidgets-B4a0JkUz.js → CodeMirrorCommentWidgets-ntXIbhV2.js} +1 -1
  5. package/dist/assets/DiffView-CjxUovbM.js +4 -0
  6. package/dist/assets/FilesView-DFWD0RQR.js +17 -0
  7. package/dist/assets/{GitView-Byu2DY7s.js → GitView-BWa7e5vw.js} +4 -4
  8. package/dist/assets/{GuestAttachDialog-B808E0-d.js → GuestAttachDialog-DdOuyZzp.js} +1 -1
  9. package/dist/assets/GuestSurfacePane-DDfzid3U.js +1 -0
  10. package/dist/assets/{JsonTreeView-VW_7czo3.js → JsonTreeView-3sGwqexh.js} +1 -1
  11. package/dist/assets/LinearIssuesView-C6dpIdjw.js +1 -0
  12. package/dist/assets/MarkdownImageGallery-Q7YR_tl4.js +1 -0
  13. package/dist/assets/MarkdownRendererImpl-DxDV56bL.js +12 -0
  14. package/dist/assets/OnboardingScreen-rK4Stg__.js +1 -0
  15. package/dist/assets/{PatchDiff-DCjGhlYV.js → PatchDiff-DS-IMU1j.js} +1 -1
  16. package/dist/assets/{PierreDiffViewer-CEDZNkU2.js → PierreDiffViewer-DW23KWKZ.js} +1 -1
  17. package/dist/assets/PlanView-BCk_VobP.js +5 -0
  18. package/dist/assets/PluginPane-BPP1dbck.js +3 -0
  19. package/dist/assets/SettingsView-kMmrpn43.js +29 -0
  20. package/dist/assets/SettingsWindow-BpvD6IYQ.js +1 -0
  21. package/dist/assets/SubmoduleDiffSummary-BLttSS1X.js +1 -0
  22. package/dist/assets/ToolOutputDialog-CtqDqbyr.js +15 -0
  23. package/dist/assets/ToolPartDiffPreview-5FcKdyRy.js +8 -0
  24. package/dist/assets/{VirtualizedFileDiff-NU0FHFhT.js → VirtualizedFileDiff-C763hNCR.js} +4 -4
  25. package/dist/assets/{WalkthroughView-BZyjhqHv.js → WalkthroughView-lap5btCF.js} +2 -2
  26. package/dist/assets/appThemeRegistry-u4zw1l0B.js +1 -0
  27. package/dist/assets/{commitAndPush-BDftrjr6.js → commitAndPush-CwGJfJG-.js} +1 -1
  28. package/dist/assets/{computeVirtualFileMetrics-DlWlEM3x.js → computeVirtualFileMetrics-DW5jjX4n.js} +1 -1
  29. package/dist/assets/de-CndEg67T.js +10 -0
  30. package/dist/assets/es-DglvPlaC.js +16 -0
  31. package/dist/assets/fr-DejPCpbS.js +14 -0
  32. package/dist/assets/index-C2r64QfD.js +1 -0
  33. package/dist/assets/index-CLcM8HKx.css +1 -0
  34. package/dist/assets/index-CvLgnuNI.js +41 -0
  35. package/dist/assets/{isDiffPlainText-CO4e5J_S.js → isDiffPlainText-D47a6hMP.js} +1 -1
  36. package/dist/assets/{isFilePlainText-ue6kCou7.js → isFilePlainText-B-mGgeKl.js} +1 -1
  37. package/dist/assets/ja-CVpQawCd.js +16 -0
  38. package/dist/assets/ko-Bw446h9j.js +16 -0
  39. package/dist/assets/main-Bz4WWXbr.js +2 -0
  40. package/dist/assets/main-oreqDsOV.js +896 -0
  41. package/dist/assets/{markdownCore-CK48LG0z.js → markdownCore-D2e5sp9y.js} +4 -4
  42. package/dist/assets/miniChat-D1v2gsBI.js +2 -0
  43. package/dist/assets/mobile-DBj3lCf2.js +2 -0
  44. package/dist/assets/{patchFileDiff-D7QVlovD.js → patchFileDiff-BpeUOV91.js} +3 -3
  45. package/dist/assets/pl-BYNlPdhm.js +16 -0
  46. package/dist/assets/pt-BR-CL-Yfv9H.js +16 -0
  47. package/dist/assets/renderElectronMiniChatApp-DVtfEB2z.js +2 -0
  48. package/dist/assets/renderMobileApp-DFp9-xbG.js +25 -0
  49. package/dist/assets/runtimeEndpointReset-CsIpUep1.js +27 -0
  50. package/dist/assets/scheduledTasksApi-B5OHVpPM.js +1 -0
  51. package/dist/assets/{shikiHighlight-BPu3JiQs.js → shikiHighlight-DNm2Z_Jc.js} +1 -1
  52. package/dist/assets/{textMateThemeFromAppTheme-DWqRPo9A.js → textMateThemeFromAppTheme-DGF2kG7g.js} +1 -1
  53. package/dist/assets/tr-DzmbxLy-.js +7 -0
  54. package/dist/assets/uk-C7vQnwro.js +16 -0
  55. package/dist/assets/useAppFontEffects-Ci3FEaPf.js +7337 -0
  56. package/dist/assets/{useInlineCommentController-CgS0fnw_.js → useInlineCommentController-CuUuakrt.js} +2 -2
  57. package/dist/assets/useRootScrollLock-CQIsyERq.js +2 -0
  58. package/dist/assets/{vendor-codemirror-language-data-Bx2EHc1O.js → vendor-codemirror-language-data-CQa7_1cr.js} +2 -2
  59. package/dist/assets/vendor-cron-parser-CjvYVigk.js +2 -0
  60. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +12 -0
  61. package/dist/assets/{vendor-remixicon-react-BDmGmFCA.js → vendor-remixicon-react-D6dZ0UwF.js} +1 -1
  62. package/dist/assets/{vendor-zod-CgAdXWZO.js → vendor-zod-Gea9uBlY.js} +1 -1
  63. package/dist/assets/{web-components-DxX8bZ1o.js → web-components-B6tKQyJN.js} +2 -2
  64. package/dist/assets/zh-CN-Cl0srS-z.js +16 -0
  65. package/dist/assets/zh-TW-DtII5bDb.js +16 -0
  66. package/dist/index.html +13 -7
  67. package/dist/mini-chat.html +5 -5
  68. package/dist/mobile.html +5 -5
  69. package/package.json +6 -5
  70. package/server/index.d.ts +1 -0
  71. package/server/index.js +246 -91
  72. package/server/lib/agent-tool/DOCUMENTATION.md +32 -15
  73. package/server/lib/agent-tool/runtime.js +135 -54
  74. package/server/lib/agent-tool/runtime.test.js +155 -87
  75. package/server/lib/browser-control/DOCUMENTATION.md +37 -2
  76. package/server/lib/browser-control/provider.js +217 -0
  77. package/server/lib/browser-control/provider.test.js +251 -0
  78. package/server/lib/context-obligatory/runtime.js +61 -50
  79. package/server/lib/context-obligatory/runtime.test.js +62 -162
  80. package/server/lib/event-stream/DOCUMENTATION.md +12 -1
  81. package/server/lib/event-stream/delta-coalescer.js +0 -0
  82. package/server/lib/event-stream/delta-coalescer.test.js +68 -57
  83. package/server/lib/event-stream/directory-ws-bridge.js +1 -1
  84. package/server/lib/event-stream/global-hub.js +10 -1
  85. package/server/lib/event-stream/global-hub.test.js +27 -26
  86. package/server/lib/event-stream/index.js +6 -0
  87. package/server/lib/event-stream/protocol.js +26 -19
  88. package/server/lib/event-stream/protocol.test.js +18 -7
  89. package/server/lib/event-stream/rebind.test.js +5 -5
  90. package/server/lib/event-stream/runtime.test.js +17 -15
  91. package/server/lib/event-stream/translate-v2.js +313 -0
  92. package/server/lib/event-stream/translate-v2.test.js +209 -0
  93. package/server/lib/event-stream/upstream-reader.test.js +16 -16
  94. package/server/lib/fs/DOCUMENTATION.md +4 -2
  95. package/server/lib/fs/byte-range.js +35 -0
  96. package/server/lib/fs/byte-range.test.js +31 -0
  97. package/server/lib/fs/routes.js +47 -42
  98. package/server/lib/fs/routes.test.js +123 -18
  99. package/server/lib/git/service.js +12 -0
  100. package/server/lib/github/DOCUMENTATION.md +1 -1
  101. package/server/lib/github/pr-status.js +22 -2
  102. package/server/lib/github/pr-status.test.js +27 -14
  103. package/server/lib/guests/DOCUMENTATION.md +13 -0
  104. package/server/lib/guests/catalog.js +12 -0
  105. package/server/lib/guests/catalog.test.js +33 -0
  106. package/server/lib/guests/routes.js +10 -1
  107. package/server/lib/guests/service.js +192 -14
  108. package/server/lib/guests/service.test.js +201 -1
  109. package/server/lib/guests/surface.js +630 -0
  110. package/server/lib/guests/surface.test.js +486 -0
  111. package/server/lib/markdown-image-grants/routes.js +18 -5
  112. package/server/lib/markdown-image-grants/routes.test.js +9 -6
  113. package/server/lib/message-queue/DOCUMENTATION.md +33 -22
  114. package/server/lib/message-queue/runtime.js +155 -100
  115. package/server/lib/message-queue/runtime.test.js +168 -82
  116. package/server/lib/notifications/DOCUMENTATION.md +28 -0
  117. package/server/lib/notifications/control-stream.http.test.js +68 -0
  118. package/server/lib/notifications/emitter-runtime.js +15 -0
  119. package/server/lib/notifications/emitter-runtime.test.js +32 -0
  120. package/server/lib/notifications/push-runtime.js +5 -1
  121. package/server/lib/notifications/push-runtime.test.js +2 -2
  122. package/server/lib/notifications/routes.js +1 -1
  123. package/server/lib/notifications/runtime.js +115 -26
  124. package/server/lib/notifications/template-runtime.js +13 -15
  125. package/server/lib/notifications/template-runtime.test.js +16 -9
  126. package/server/lib/notifications/trigger-runtime.test.js +187 -0
  127. package/server/lib/openchamber-control/DOCUMENTATION.md +10 -0
  128. package/server/lib/openchamber-control/actions.js +3 -2
  129. package/server/lib/openchamber-control/error.js +1 -1
  130. package/server/lib/openchamber-control/file-open.js +59 -0
  131. package/server/lib/openchamber-control/file-open.test.js +57 -0
  132. package/server/lib/openchamber-control/routes.test.js +12 -0
  133. package/server/lib/openchamber-control/service.js +102 -49
  134. package/server/lib/openchamber-control/service.test.js +84 -44
  135. package/server/lib/openchamber-sessions/archive-store.js +224 -0
  136. package/server/lib/openchamber-sessions/archive-store.test.js +187 -0
  137. package/server/lib/openchamber-sessions/opencode-client.js +22 -0
  138. package/server/lib/openchamber-sessions/routes.js +398 -328
  139. package/server/lib/openchamber-sessions/routes.test.js +777 -740
  140. package/server/lib/openchamber-sessions/session-metadata-store.js +285 -0
  141. package/server/lib/openchamber-sessions/session-metadata-store.test.js +259 -0
  142. package/server/lib/opencode/DOCUMENTATION.md +449 -29
  143. package/server/lib/opencode/agents.js +233 -327
  144. package/server/lib/opencode/auth.js +41 -44
  145. package/server/lib/opencode/auth.test.js +92 -0
  146. package/server/lib/opencode/cli-upgrade.d.ts +4 -0
  147. package/server/lib/opencode/cli-upgrade.js +18 -0
  148. package/server/lib/opencode/cli-upgrade.test.js +36 -0
  149. package/server/lib/opencode/commands.js +164 -145
  150. package/server/lib/opencode/compatibility.d.ts +16 -0
  151. package/server/lib/opencode/compatibility.js +88 -0
  152. package/server/lib/opencode/compatibility.test.js +62 -0
  153. package/server/lib/opencode/config-entity-routes.js +58 -29
  154. package/server/lib/opencode/config-entity-v2.test.js +290 -0
  155. package/server/lib/opencode/config-mutation-response.js +17 -7
  156. package/server/lib/opencode/config-mutation-response.test.js +4 -7
  157. package/server/lib/opencode/config-paths.test.js +2 -2
  158. package/server/lib/opencode/config-v2.d.ts +183 -0
  159. package/server/lib/opencode/config-v2.js +801 -0
  160. package/server/lib/opencode/config-v2.test.js +466 -0
  161. package/server/lib/opencode/credential-db.js +144 -0
  162. package/server/lib/opencode/credential-db.test.js +104 -0
  163. package/server/lib/opencode/env-runtime.js +48 -9
  164. package/server/lib/opencode/env-runtime.test.js +45 -0
  165. package/server/lib/opencode/feature-routes-runtime.js +15 -5
  166. package/server/lib/opencode/lifecycle-process.test.js +7 -4
  167. package/server/lib/opencode/lifecycle.js +85 -30
  168. package/server/lib/opencode/lifecycle.test.js +80 -8
  169. package/server/lib/opencode/login-shell-env.js +27 -0
  170. package/server/lib/opencode/managed-config-file.js +119 -0
  171. package/server/lib/opencode/managed-config-file.test.js +152 -0
  172. package/server/lib/opencode/managed-plugin-config.js +33 -13
  173. package/server/lib/opencode/mcp.js +43 -149
  174. package/server/lib/opencode/network-runtime.js +6 -7
  175. package/server/lib/opencode/openchamber-routes.test.js +2 -5
  176. package/server/lib/opencode/plugin-routes.js +3 -3
  177. package/server/lib/opencode/plugin-routes.test.js +6 -24
  178. package/server/lib/opencode/plugins.js +91 -40
  179. package/server/lib/opencode/plugins.test.js +28 -15
  180. package/server/lib/opencode/providers.js +63 -83
  181. package/server/lib/opencode/providers.test.js +128 -59
  182. package/server/lib/opencode/proxy-agent-wiring.test.js +4 -2
  183. package/server/lib/opencode/proxy.js +179 -129
  184. package/server/lib/opencode/pwa-manifest-routes.js +4 -2
  185. package/server/lib/opencode/pwa-manifest-routes.test.js +3 -2
  186. package/server/lib/opencode/response-envelope.js +14 -0
  187. package/server/lib/opencode/response-envelope.test.js +23 -0
  188. package/server/lib/opencode/routes-upgrade.test.js +73 -116
  189. package/server/lib/opencode/routes.js +121 -428
  190. package/server/lib/opencode/server-utils-runtime.js +10 -4
  191. package/server/lib/opencode/session-activity.js +78 -0
  192. package/server/lib/opencode/session-runtime.js +21 -10
  193. package/server/lib/opencode/session-runtime.test.js +9 -9
  194. package/server/lib/opencode/settings-helpers.js +9 -9
  195. package/server/lib/opencode/settings-helpers.test.js +7 -12
  196. package/server/lib/opencode/settings-registry.json +6 -8
  197. package/server/lib/opencode/settings-runtime.js +18 -1
  198. package/server/lib/opencode/settings-runtime.test.js +31 -2
  199. package/server/lib/opencode/shared.js +54 -22
  200. package/server/lib/opencode/shared.test.js +8 -4
  201. package/server/lib/opencode/shutdown-runtime.js +54 -7
  202. package/server/lib/opencode/shutdown-runtime.test.js +224 -30
  203. package/server/lib/opencode/skill-routes.js +23 -24
  204. package/server/lib/opencode/skills.js +7 -5
  205. package/server/lib/opencode/tunnel-wiring-runtime.js +2 -1
  206. package/server/lib/opencode/upgrade-capability.test.js +1 -1
  207. package/server/lib/opencode/v1-migration-topup.js +288 -0
  208. package/server/lib/opencode/v1-migration-topup.test.js +266 -0
  209. package/server/lib/opencode/v2-install.d.ts +8 -0
  210. package/server/lib/opencode/v2-install.js +158 -0
  211. package/server/lib/opencode/v2-install.test.js +124 -0
  212. package/server/lib/opencode/watcher.js +10 -3
  213. package/server/lib/opencode/watcher.test.js +17 -21
  214. package/server/lib/opencode/websearch-config.js +66 -0
  215. package/server/lib/package-manager.js +55 -68
  216. package/server/lib/package-manager.test.js +241 -75
  217. package/server/lib/permission-auto-accept/runtime.js +38 -22
  218. package/server/lib/permission-auto-accept/runtime.test.js +39 -17
  219. package/server/lib/quota/DOCUMENTATION.md +4 -0
  220. package/server/lib/quota/providers/xai.js +4 -4
  221. package/server/lib/relay/tunnel-host.js +4 -1
  222. package/server/lib/relay/tunnel-host.test.js +7 -0
  223. package/server/lib/routing/DOCUMENTATION.md +52 -21
  224. package/server/lib/routing/defaults.js +23 -6
  225. package/server/lib/routing/jev.js +17 -7
  226. package/server/lib/routing/routes.http.test.js +68 -33
  227. package/server/lib/routing/routes.js +65 -22
  228. package/server/lib/routing/runtime.js +145 -48
  229. package/server/lib/routing/runtime.test.js +79 -55
  230. package/server/lib/scheduled-tasks/DOCUMENTATION.md +4 -0
  231. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +51 -46
  232. package/server/lib/scheduled-tasks/runtime.js +81 -74
  233. package/server/lib/scheduled-tasks/runtime.test.js +53 -1
  234. package/server/lib/session-assist/DOCUMENTATION.md +8 -3
  235. package/server/lib/session-assist/context.js +122 -46
  236. package/server/lib/session-assist/context.test.js +117 -49
  237. package/server/lib/session-assist/runtime.js +52 -22
  238. package/server/lib/session-assist/runtime.test.js +145 -173
  239. package/server/lib/session-goal/DOCUMENTATION.md +23 -15
  240. package/server/lib/session-goal/create.js +9 -12
  241. package/server/lib/session-goal/create.test.js +27 -28
  242. package/server/lib/session-goal/runtime.js +164 -77
  243. package/server/lib/session-goal/runtime.test.js +271 -519
  244. package/server/lib/session-knowledge/DOCUMENTATION.md +5 -0
  245. package/server/lib/session-knowledge/runtime.js +59 -41
  246. package/server/lib/session-knowledge/runtime.test.js +90 -20
  247. package/server/lib/small-model/DOCUMENTATION.md +154 -208
  248. package/server/lib/small-model/client.js +113 -0
  249. package/server/lib/small-model/index.js +296 -158
  250. package/server/lib/small-model/index.test.js +423 -256
  251. package/server/lib/small-model/routes.js +1 -1
  252. package/server/lib/spaces/DOCUMENTATION.md +807 -0
  253. package/server/lib/spaces/code-in-bait.js +185 -0
  254. package/server/lib/spaces/code-in.js +641 -0
  255. package/server/lib/spaces/code-in.test.js +1048 -0
  256. package/server/lib/spaces/errors.js +8 -0
  257. package/server/lib/spaces/exec-http.js +94 -0
  258. package/server/lib/spaces/gatekeeper-channel.js +202 -0
  259. package/server/lib/spaces/gatekeeper-channel.test.js +215 -0
  260. package/server/lib/spaces/gatekeeper-program.cjs +914 -0
  261. package/server/lib/spaces/gatekeeper-program.test.js +1391 -0
  262. package/server/lib/spaces/hardening.js +457 -0
  263. package/server/lib/spaces/hardening.test.js +368 -0
  264. package/server/lib/spaces/host-git.js +127 -0
  265. package/server/lib/spaces/host-git.test.js +137 -0
  266. package/server/lib/spaces/labels.js +234 -0
  267. package/server/lib/spaces/labels.test.js +219 -0
  268. package/server/lib/spaces/layout.js +184 -0
  269. package/server/lib/spaces/layout.test.js +30 -0
  270. package/server/lib/spaces/manager.js +82 -0
  271. package/server/lib/spaces/manager.test.js +114 -0
  272. package/server/lib/spaces/places/code-in.docker.live.test.js +216 -0
  273. package/server/lib/spaces/places/contract-suite.js +156 -0
  274. package/server/lib/spaces/places/contract.docker.live.test.js +7 -0
  275. package/server/lib/spaces/places/contract.test.js +6 -0
  276. package/server/lib/spaces/places/docker-engine.js +126 -0
  277. package/server/lib/spaces/places/docker-live-support.js +202 -0
  278. package/server/lib/spaces/places/docker-tools.js +186 -0
  279. package/server/lib/spaces/places/docker.js +587 -0
  280. package/server/lib/spaces/places/docker.test.js +1610 -0
  281. package/server/lib/spaces/places/escape-suite.js +1018 -0
  282. package/server/lib/spaces/places/escape.docker.live.test.js +7 -0
  283. package/server/lib/spaces/places/fake-docker.js +375 -0
  284. package/server/lib/spaces/places/memory-place.js +103 -0
  285. package/server/lib/spaces/places/packed.docker.live.test.js +77 -0
  286. package/server/lib/spaces/places/registry.js +43 -0
  287. package/server/lib/spaces/places/registry.test.js +50 -0
  288. package/server/lib/spaces/places/server.docker.live.test.js +225 -0
  289. package/server/lib/spaces/run-command.js +261 -0
  290. package/server/lib/spaces/run-command.test.js +396 -0
  291. package/server/lib/spaces/space-server.js +190 -0
  292. package/server/lib/spaces/space-server.test.js +335 -0
  293. package/server/lib/spaces/tools-filler.js +82 -0
  294. package/server/lib/spaces/tools-filler.test.js +148 -0
  295. package/server/lib/spaces/tools-pack.js +49 -0
  296. package/server/lib/spaces/tools-pack.test.js +57 -0
  297. package/server/lib/spaces/tools.js +138 -0
  298. package/server/lib/spaces/tools.test.js +153 -0
  299. package/server/lib/tts/openai-sdk.js +11 -0
  300. package/server/lib/tts/service.js +7 -5
  301. package/server/lib/tts/stt.js +3 -2
  302. package/server/lib/tunnels/DOCUMENTATION.md +2 -0
  303. package/server/lib/tunnels/routes.js +7 -0
  304. package/server/lib/tunnels/routes.test.js +55 -0
  305. package/server/lib/ui-auth/ui-auth.js +38 -2
  306. package/server/lib/ui-auth/ui-auth.test.js +42 -0
  307. package/server/lib/ui-auth/ui-passkeys.js +12 -10
  308. package/server/lib/walkthrough/DOCUMENTATION.md +10 -1
  309. package/server/lib/walkthrough/pull-request.js +61 -8
  310. package/server/lib/walkthrough/pull-request.test.js +54 -1
  311. package/server/lib/walkthrough/routes.js +20 -0
  312. package/server/lib/walkthrough/routes.test.js +19 -0
  313. package/server/opencode-proxy.test.js +190 -166
  314. package/server/opencode-worktree-readiness.test.js +5 -4
  315. package/dist/assets/DiffView-B2CD9wC8.js +0 -4
  316. package/dist/assets/FilesView-C4vMFclc.js +0 -13
  317. package/dist/assets/LinearIssuesView-EzGNCGrd.js +0 -1
  318. package/dist/assets/MarkdownImageGallery-B2YUDFaB.js +0 -1
  319. package/dist/assets/MarkdownRendererImpl-B4ikIzR3.js +0 -12
  320. package/dist/assets/OnboardingScreen-CQHBSTEi.js +0 -1
  321. package/dist/assets/PlanView-D3x-pTkZ.js +0 -5
  322. package/dist/assets/PluginPane-DEcgDQ9h.js +0 -3
  323. package/dist/assets/SettingsView-CSinuH-1.js +0 -29
  324. package/dist/assets/SettingsWindow-B5CHoJFZ.js +0 -1
  325. package/dist/assets/SubmoduleDiffSummary-oxeACCWs.js +0 -1
  326. package/dist/assets/ToolOutputDialog-tLNWmfcW.js +0 -15
  327. package/dist/assets/ToolPartDiffPreview-CflzmK-x.js +0 -8
  328. package/dist/assets/appThemeRegistry-DLicw4dH.js +0 -1
  329. package/dist/assets/de-Bro6_zyl.js +0 -10
  330. package/dist/assets/es-Bb9XQZvd.js +0 -16
  331. package/dist/assets/fr-Dgfzn-IL.js +0 -14
  332. package/dist/assets/index-CtQNmqUL.js +0 -1
  333. package/dist/assets/index-DqaMWNrC.css +0 -1
  334. package/dist/assets/index-Dqc6USl3.js +0 -40
  335. package/dist/assets/ja-BYsakGFh.js +0 -16
  336. package/dist/assets/ko-BSaQz9aN.js +0 -16
  337. package/dist/assets/main-B62_pjL5.js +0 -2
  338. package/dist/assets/main-BEdcOI2P.js +0 -896
  339. package/dist/assets/miniChat-LT8Rqofr.js +0 -2
  340. package/dist/assets/mobile-CqarEynh.js +0 -2
  341. package/dist/assets/pl-OIviPdoj.js +0 -16
  342. package/dist/assets/pt-BR-Cltg-E6s.js +0 -16
  343. package/dist/assets/renderElectronMiniChatApp-COyLTtZx.js +0 -2
  344. package/dist/assets/renderMobileApp-BC1MHg6g.js +0 -25
  345. package/dist/assets/runtimeEndpointReset-DdrazOVH.js +0 -25
  346. package/dist/assets/scheduledTasksApi-B2SWMGEU.js +0 -1
  347. package/dist/assets/tr-CQI-KPre.js +0 -7
  348. package/dist/assets/uk-CqW7R4DA.js +0 -16
  349. package/dist/assets/useAppFontEffects-ne84JBVL.js +0 -7333
  350. package/dist/assets/useRootScrollLock-Cpf1qoGp.js +0 -2
  351. package/dist/assets/vendor-cron-parser-CUcP7OfX.js +0 -2
  352. package/dist/assets/vendor-opencode-sdk-BJ73u-bH.js +0 -7
  353. package/dist/assets/zh-CN-CVZG_o_J.js +0 -16
  354. package/dist/assets/zh-TW-D5ZxzCZp.js +0 -16
  355. package/server/lib/opencode/mcp-oauth-callback.test.js +0 -111
  356. package/server/lib/routing/feature-flag.js +0 -11
  357. package/server/lib/small-model/call.js +0 -851
  358. package/server/lib/small-model/call.test.js +0 -1292
  359. package/server/lib/small-model/catalog.js +0 -13
  360. package/server/lib/small-model/resolve.js +0 -131
  361. package/server/lib/small-model/resolve.test.js +0 -197
  362. package/server/lib/small-model/runtime-providers.js +0 -164
  363. package/server/lib/small-model/runtime-providers.test.js +0 -118
  364. package/server/lib/system-prompt/DOCUMENTATION.md +0 -36
  365. package/server/lib/system-prompt/runtime.js +0 -49
  366. package/server/lib/system-prompt/runtime.test.js +0 -76
  367. package/server/lib/walkthrough/reproduce-2607.test.js +0 -148
@@ -0,0 +1,1048 @@
1
+ // Code in, with real host git in temporary directories and no Docker. The live file under places/
2
+ // proves the same path into a real space.
3
+
4
+ import { spawn, spawnSync } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import net from 'node:net';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+
10
+ import { afterAll, describe, expect, it } from 'vitest';
11
+
12
+ import { SECRET, SETUP_GIT, blob, createTestHost, forConfig, hostState, makeBait, readTree, removeTestHosts, shortStatus, unexpectedChanges } from './code-in-bait.js';
13
+ import { buildExtUrl, createCodeIn } from './code-in.js';
14
+ import { SpaceError } from './errors.js';
15
+ import { createHostGit } from './host-git.js';
16
+ import { IMAGE_ONLY_PATH, IMAGE_SH } from './layout.js';
17
+ import { runCommand } from './run-command.js';
18
+
19
+ const WIN = process.platform === 'win32';
20
+ // `*` cannot be in a file name on Windows; brackets, `!`, `#`, spaces and non-ASCII can.
21
+ const ODD_NAME = WIN ? 'odd [!] #name ї' : 'odd [*] name';
22
+ const SPACE_ID = 'a1b2c3d4e5f6';
23
+ const OTHER_SPACE_ID = 'f6e5d4c3b2a1';
24
+
25
+ afterAll(removeTestHosts);
26
+
27
+ /** What changed in a repository folder since `before`, apart from what code in writes by design for these spaces. */
28
+ const changedSince = (before, directory, spaceIds = [SPACE_ID]) => unexpectedChanges(before, hostState(directory), { spaceIds });
29
+
30
+ const snapshotOf = (host, repository, mode = 'uncommitted') => host.codeIn().takeSnapshot({ repository, spaceId: SPACE_ID, mode });
31
+
32
+ /** A committed repository of its own in `folder` of `repo`, or one with no commit. */
33
+ const nestRepository = (host, repo, folder, { commit = true } = {}) => {
34
+ const nested = path.join(repo, folder);
35
+ host.sh(repo, ['init', '--quiet', folder]);
36
+ fs.writeFileSync(path.join(nested, 'inner.txt'), 'inner\n');
37
+ if (commit) {
38
+ host.sh(nested, ['add', 'inner.txt']);
39
+ host.sh(nested, ['commit', '--quiet', '-m', 'inner']);
40
+ }
41
+ return nested;
42
+ };
43
+
44
+ describe('hostState and unexpectedChanges', () => {
45
+ // Each of these changes a repository in a way code in must never do. The helper has to see every one.
46
+ const CHANGES = [
47
+ ['.git/config', (repo, g) => g(['config', 'core.hooksPath', '/elsewhere']), '.git/config'],
48
+ ['a new hook', (repo) => fs.writeFileSync(path.join(repo, '.git', 'hooks', 'pre-commit'), '#!/bin/sh\n'), '.git/hooks/pre-commit'],
49
+ ['info/exclude', (repo) => fs.appendFileSync(path.join(repo, '.git', 'info', 'exclude'), 'more\n'), '.git/info/exclude'],
50
+ ['MERGE_HEAD', (repo, g) => fs.writeFileSync(path.join(repo, '.git', 'MERGE_HEAD'), g(['rev-parse', 'HEAD'])), '.git/MERGE_HEAD'],
51
+ ['a shared index file', (repo) => fs.writeFileSync(path.join(repo, '.git', `sharedindex.${'0'.repeat(40)}`), 'x'), `.git/sharedindex.${'0'.repeat(40)}`],
52
+ ['a new empty folder', (repo) => fs.mkdirSync(path.join(repo, 'empty folder')), 'empty folder'],
53
+ ['packed refs', (repo, g) => g(['pack-refs', '--all']), '.git/packed-refs'],
54
+ ['a reflog', (repo) => fs.appendFileSync(path.join(repo, '.git', 'logs', 'HEAD'), 'x\n'), '.git/logs/HEAD'],
55
+ ['a ref of ours outside this space', (repo, g) => g(['update-ref', 'refs/openchamber/other', 'HEAD']), '.git/refs/openchamber/other'],
56
+ ['the ref of another space', (repo, g) => g(['update-ref', `refs/openchamber/spaces/${OTHER_SPACE_ID}/start`, 'HEAD']), `.git/refs/openchamber/spaces/${OTHER_SPACE_ID}/start`],
57
+ ['a loose object rewritten', (repo, g) => {
58
+ const id = g(['hash-object', '-w', '--stdin'], { input: 'existing\n' }).trim();
59
+ fs.chmodSync(path.join(repo, '.git', 'objects', id.slice(0, 2), id.slice(2)), 0o644);
60
+ fs.writeFileSync(path.join(repo, '.git', 'objects', id.slice(0, 2), id.slice(2)), 'changed');
61
+ }, null],
62
+ ...(WIN ? [] : [['chmod +x of an untracked file', (repo) => fs.chmodSync(path.join(repo, 'untracked plain.txt'), 0o755), 'untracked plain.txt']]),
63
+ ];
64
+
65
+ it.each(CHANGES)('sees %s', (_, change, expectedPath) => {
66
+ const host = createTestHost();
67
+ const { repo, g } = makeBait(host);
68
+ // The object the rewrite changes has to exist before the first look.
69
+ if (expectedPath === null) g(['hash-object', '-w', '--stdin'], { input: 'existing\n' });
70
+ const before = hostState(repo);
71
+ change(repo, (args, options) => host.sh(repo, args, options));
72
+ const changed = changedSince(before, repo);
73
+ expect(changed.length).toBeGreaterThan(0);
74
+ if (expectedPath !== null) expect(changed).toContain(expectedPath);
75
+ });
76
+
77
+ // Two changes that looked like what code in writes by design, and are not.
78
+ it('sees the start ref of this space moved to another commit', () => {
79
+ const host = createTestHost();
80
+ const { repo, g } = makeBait(host);
81
+ g(['update-ref', `refs/openchamber/spaces/${SPACE_ID}/start`, 'HEAD']);
82
+ const before = hostState(repo);
83
+ g(['update-ref', `refs/openchamber/spaces/${SPACE_ID}/start`, 'HEAD~1']);
84
+ expect(changedSince(before, repo)).toEqual([`.git/refs/openchamber/spaces/${SPACE_ID}/start`]);
85
+ });
86
+
87
+ it('sees a new loose object in a nested repository', () => {
88
+ const host = createTestHost();
89
+ const { repo } = makeBait(host);
90
+ const nested = nestRepository(host, repo, 'nested');
91
+ const before = hostState(repo);
92
+ const id = host.sh(nested, ['hash-object', '-w', '--stdin'], { input: 'inside the nested one\n' }).trim();
93
+ expect(changedSince(before, repo)).toContain(`nested/.git/objects/${id.slice(0, 2)}/${id.slice(2)}`);
94
+ });
95
+
96
+ // An fsmonitor daemon keeps a socket in `.git`. Reading it crashed the helper, and a FIFO would hang it.
97
+ it.skipIf(WIN)('describes a socket and a FIFO by type, and sees each appear', async () => {
98
+ const host = createTestHost();
99
+ const { repo } = makeBait(host);
100
+ const before = hostState(repo);
101
+ // A socket path has a short length limit, so it is made in a short folder and moved in.
102
+ const short = fs.mkdtempSync('/tmp/oc-');
103
+ const server = net.createServer();
104
+ let writer = null;
105
+ await new Promise((resolve) => { server.listen(path.join(short, 's'), resolve); });
106
+ try {
107
+ fs.renameSync(path.join(short, 's'), path.join(repo, '.git', 'daemon.ipc'));
108
+ const fifo = path.join(repo, '.git', 'a-fifo');
109
+ expect(spawnSync('mkfifo', [fifo]).status).toBe(0);
110
+ // A writer waits on the other end, so a helper that read the FIFO would finish, with the wrong
111
+ // answer, instead of hanging the test.
112
+ writer = spawn('/bin/sh', ['-c', 'printf x > "$1"', 'sh', fifo], { stdio: 'ignore' });
113
+ const after = hostState(repo);
114
+ expect(after['.git/daemon.ipc']).toMatch(/^socket /);
115
+ expect(after['.git/a-fifo']).toMatch(/^fifo /);
116
+ expect(unexpectedChanges(before, after, { spaceIds: [SPACE_ID] })).toEqual(['.git/a-fifo', '.git/daemon.ipc']);
117
+ } finally {
118
+ if (Number.isInteger(writer?.pid) && writer.pid > 1 && writer.exitCode === null) writer.kill('SIGKILL');
119
+ server.close();
120
+ fs.rmSync(short, { recursive: true, force: true });
121
+ }
122
+ });
123
+
124
+ it('allows a new loose object and the start ref of this space, and nothing else', () => {
125
+ const host = createTestHost();
126
+ const { repo, g } = makeBait(host);
127
+ const before = hostState(repo);
128
+ g(['hash-object', '-w', '--stdin'], { input: 'a new object\n' });
129
+ g(['update-ref', `refs/openchamber/spaces/${SPACE_ID}/start`, 'HEAD']);
130
+ expect(changedSince(before, repo)).toEqual([]);
131
+ expect(changedSince(before, repo, [])).toEqual([
132
+ '.git/refs/openchamber',
133
+ '.git/refs/openchamber/spaces',
134
+ `.git/refs/openchamber/spaces/${SPACE_ID}`,
135
+ `.git/refs/openchamber/spaces/${SPACE_ID}/start`,
136
+ ]);
137
+ });
138
+ });
139
+
140
+ describe('takeSnapshot', () => {
141
+ it('holds the working tree in the start and the index in the staged commit, and leaves ignored files out', async () => {
142
+ const host = createTestHost();
143
+ const { repo, g } = makeBait(host);
144
+ const head = g(['rev-parse', 'HEAD']).trim();
145
+ const snapshot = await snapshotOf(host, repo);
146
+
147
+ expect(snapshot).toMatchObject({ base: head, branch: 'main', objectFormat: 'sha1', identity: { name: 'Bait Author', email: 'bait@example.invalid' } });
148
+ expect(g(['rev-parse', `${snapshot.staged}^`]).trim()).toBe(head);
149
+ expect(g(['rev-parse', `${snapshot.start}^`]).trim()).toBe(snapshot.staged);
150
+ expect(g(['log', '-1', '--format=%an <%ae>', snapshot.start]).trim()).toBe('OpenChamber <spaces@openchamber.invalid>');
151
+
152
+ const start = readTree(g, snapshot.start);
153
+ expect(start['run.sh'].mode).toBe('100755');
154
+ expect(blob(g, snapshot.start, 'tracked-to-edit.txt')).toBe('one\ntwo staged\nthree unstaged\n');
155
+ expect(Object.keys(start)).toEqual(expect.arrayContaining(['staged-new.txt', 'untracked plain.txt', 'юнікод.txt', '.gitignore', 'README.md']));
156
+ expect(start['tracked-to-delete.txt']).toBeUndefined();
157
+ if (!WIN) expect(start['link-to-readme'].mode).toBe('120000');
158
+ // Ignored by the repository, and by the global ignore file only.
159
+ expect(start['.env']).toBeUndefined();
160
+ expect(Object.keys(start).filter((file) => file.startsWith('node_modules/'))).toEqual([]);
161
+ expect(start['secret-by-global-ignore.txt']).toBeUndefined();
162
+ for (const file of Object.keys(start).filter((name) => start[name].mode !== '120000')) {
163
+ expect(blob(g, snapshot.start, file)).not.toContain(SECRET);
164
+ }
165
+
166
+ const staged = readTree(g, snapshot.staged);
167
+ expect(blob(g, snapshot.staged, 'tracked-to-edit.txt')).toBe('one\ntwo staged\n');
168
+ expect(staged['tracked-to-delete.txt']).toBeDefined();
169
+ expect(staged['staged-new.txt']).toBeDefined();
170
+ expect(staged['untracked plain.txt']).toBeUndefined();
171
+ });
172
+
173
+ // The snapshot runs with the user's normal config for this. Run without the global config, it
174
+ // would carry every file the user keeps out through their global ignore file.
175
+ it('keeps a file that only the global ignore file names out of the snapshot and out of the list', async () => {
176
+ const host = createTestHost();
177
+ const { repo, g } = makeBait(host);
178
+ const snapshot = await snapshotOf(host, repo);
179
+ expect(Object.keys(readTree(g, snapshot.start))).not.toContain('secret-by-global-ignore.txt');
180
+ expect((await host.codeIn().listTravellingFiles(repo)).files.map((file) => file.path)).not.toContain('secret-by-global-ignore.txt');
181
+ });
182
+
183
+ it('changes nothing in the repository folder, .git included, but new loose objects and its own ref', async () => {
184
+ const host = createTestHost();
185
+ const { repo } = makeBait(host);
186
+ const before = hostState(repo);
187
+ await snapshotOf(host, repo);
188
+ expect(changedSince(before, repo)).toEqual([]);
189
+ });
190
+
191
+ it('keeps the start alive with a host ref, refuses a second one, and removes the refs of one space only', async () => {
192
+ const host = createTestHost();
193
+ const { repo, g } = makeBait(host);
194
+ const codeIn = host.codeIn();
195
+ const snapshot = await snapshotOf(host, repo);
196
+ const other = await codeIn.takeSnapshot({ repository: repo, spaceId: OTHER_SPACE_ID, mode: 'clean' });
197
+ expect(g(['rev-parse', `refs/openchamber/spaces/${SPACE_ID}/start`]).trim()).toBe(snapshot.start);
198
+ const before = hostState(repo);
199
+ await expect(snapshotOf(host, repo)).rejects.toMatchObject({ code: 'space_ref_exists' });
200
+ expect(changedSince(before, repo, [])).toEqual([]);
201
+
202
+ expect(await codeIn.removeSpaceRefs({ repository: repo, spaceId: SPACE_ID })).toEqual([`refs/openchamber/spaces/${SPACE_ID}/start`]);
203
+ expect(g(['for-each-ref', '--format=%(refname) %(objectname)', 'refs/openchamber/'])).toBe(`refs/openchamber/spaces/${OTHER_SPACE_ID}/start ${other.start}\n`);
204
+ expect(await codeIn.removeSpaceRefs({ repository: repo, spaceId: SPACE_ID })).toEqual([]);
205
+ expect(changedSince(before, repo)).toEqual([]);
206
+ });
207
+
208
+ it('starts at HEAD with nothing uncommitted in the clean mode', async () => {
209
+ const host = createTestHost();
210
+ const { repo, g } = makeBait(host);
211
+ const head = g(['rev-parse', 'HEAD']).trim();
212
+ expect(await snapshotOf(host, repo, 'clean')).toMatchObject({ base: head, staged: head, start: head });
213
+ });
214
+
215
+ it('does not sign the snapshot commits for a user who signs every commit', async () => {
216
+ const host = createTestHost();
217
+ const { repo, g } = makeBait(host);
218
+ host.addConfig('[commit]\n\tgpgSign = true\n[gpg]\n\tprogram = openchamber-no-such-gpg-program');
219
+ // The control: with this config a commit tries to sign and fails. Measured with git 2.50.1,
220
+ // `commit-tree` itself ignores `commit.gpgSign`, so `--no-gpg-sign` there guards against a git
221
+ // that reads it; this test proves the outcome, a snapshot that works, whichever git runs.
222
+ const control = path.join(host.root, 'signing control');
223
+ host.sh(host.root, ['init', '--quiet', control]);
224
+ expect(spawnSync('git', [...SETUP_GIT, '-C', control, 'commit', '--allow-empty', '-m', 'control'], { env: host.environment, windowsHide: true }).status).not.toBe(0);
225
+
226
+ const snapshot = await snapshotOf(host, repo);
227
+ expect(g(['cat-file', 'commit', snapshot.start])).not.toContain('gpgsig');
228
+ });
229
+
230
+ it('works for a user without a git identity, and guesses none', async () => {
231
+ const host = createTestHost({ identity: false });
232
+ const { repo } = makeBait(host);
233
+ expect((await snapshotOf(host, repo)).identity).toEqual({ name: '', email: '' });
234
+ });
235
+
236
+ it('refuses a folder outside a working tree, a bare repository, a repository with no commit, and unresolved conflicts, and changes nothing', async () => {
237
+ const host = createTestHost();
238
+ const refuses = async (repository, code) => {
239
+ const before = hostState(repository);
240
+ await expect(snapshotOf(host, repository)).rejects.toMatchObject({ code });
241
+ expect(changedSince(before, repository, [])).toEqual([]);
242
+ };
243
+
244
+ const plain = path.join(host.root, 'plain folder');
245
+ fs.mkdirSync(plain);
246
+ fs.writeFileSync(path.join(plain, 'a.txt'), 'a\n');
247
+ await refuses(plain, 'not_a_git_work_tree');
248
+
249
+ const bare = path.join(host.root, 'bare.git');
250
+ host.sh(host.root, ['init', '--quiet', '--bare', bare]);
251
+ await refuses(bare, 'not_a_git_work_tree');
252
+
253
+ const unborn = path.join(host.root, 'unborn');
254
+ host.sh(host.root, ['init', '--quiet', unborn]);
255
+ fs.writeFileSync(path.join(unborn, 'a.txt'), 'a\n');
256
+ await refuses(unborn, 'repository_has_no_commit');
257
+
258
+ const { repo, g } = makeBait(host, { name: 'conflict' });
259
+ g(['stash', '--include-untracked', '--quiet']);
260
+ g(['checkout', '--quiet', '-b', 'other']);
261
+ fs.writeFileSync(path.join(repo, 'history.txt'), 'other side\n');
262
+ g(['commit', '--quiet', '-am', 'other side']);
263
+ g(['checkout', '--quiet', 'main']);
264
+ fs.writeFileSync(path.join(repo, 'history.txt'), 'main side\n');
265
+ g(['commit', '--quiet', '-am', 'main side']);
266
+ expect(spawnSync('git', [...SETUP_GIT, '-C', repo, 'merge', 'other'], { env: host.environment, windowsHide: true }).status).not.toBe(0);
267
+ await refuses(repo, 'repository_has_unmerged_changes');
268
+ });
269
+
270
+ it.each([
271
+ ['ignored', (repo) => { fs.mkdirSync(path.join(repo, 'scratch')); fs.writeFileSync(path.join(repo, 'scratch', 'notes.txt'), 'notes\n'); fs.appendFileSync(path.join(repo, '.gitignore'), 'scratch/\n'); }],
272
+ ['empty', (repo) => { fs.mkdirSync(path.join(repo, 'scratch')); }],
273
+ ])('refuses a project in a subfolder that is %s, before anything reaches the space, and changes nothing but loose objects', async (_, make) => {
274
+ const host = createTestHost();
275
+ const { repo, g } = makeBait(host);
276
+ make(repo);
277
+ const before = hostState(repo);
278
+ const place = { exec: async () => { throw new Error('the space must not be asked'); }, execArgv: async () => { throw new Error('the space must not be asked'); } };
279
+ for (const mode of ['uncommitted', 'clean']) {
280
+ await expect(host.codeIn(place).bringCodeIn({ repository: path.join(repo, 'scratch'), spaceId: SPACE_ID, mode })).rejects.toMatchObject({ code: 'project_folder_does_not_travel' });
281
+ }
282
+ expect(g(['for-each-ref', 'refs/openchamber/'])).toBe('');
283
+ expect(changedSince(before, repo, [])).toEqual([]);
284
+ });
285
+
286
+ it('keeps untracked repositories out of the snapshot, with or without a commit, however they are named', async () => {
287
+ const host = createTestHost();
288
+ const { repo, g } = makeBait(host);
289
+ nestRepository(host, repo, 'nested');
290
+ nestRepository(host, repo, ODD_NAME, { commit: false });
291
+ // Read as a pattern, `file[s]` would also exclude the untracked file `files`, which must travel.
292
+ nestRepository(host, repo, 'file[s]');
293
+ // Pathspecs separated by lines instead of NUL would split this one in two.
294
+ if (!WIN) nestRepository(host, repo, 'line\nbreak');
295
+ fs.writeFileSync(path.join(repo, 'files'), 'travels despite the repository next to it\n');
296
+ const before = hostState(repo);
297
+ const snapshot = await snapshotOf(host, repo);
298
+ const start = Object.keys(readTree(g, snapshot.start));
299
+ expect(start.filter((file) => file.startsWith('nested') || file.startsWith('odd') || file.startsWith('file[') || file.startsWith('line'))).toEqual([]);
300
+ expect(start).toContain('files');
301
+ expect(start).toContain('untracked plain.txt');
302
+ expect(start).not.toContain('tracked-to-delete.txt');
303
+ expect(changedSince(before, repo)).toEqual([]);
304
+ });
305
+
306
+ it('works in a SHA-256 repository', async () => {
307
+ const host = createTestHost();
308
+ const { repo, g } = makeBait(host, { objectFormat: 'sha256' });
309
+ const before = hostState(repo);
310
+ const snapshot = await snapshotOf(host, repo);
311
+ expect(snapshot.objectFormat).toBe('sha256');
312
+ expect(snapshot.start).toMatch(/^[0-9a-f]{64}$/);
313
+ expect(readTree(g, snapshot.start)['untracked plain.txt']).toBeDefined();
314
+ expect(changedSince(before, repo)).toEqual([]);
315
+ });
316
+
317
+ it('works in a linked worktree, from its own index and HEAD, and leaves both checkouts alone', async () => {
318
+ const host = createTestHost();
319
+ const { repo, g } = makeBait(host);
320
+ const worktree = path.join(host.root, 'linked worktree');
321
+ g(['worktree', 'add', '--quiet', '-b', 'feature', worktree]);
322
+ const w = (args) => host.sh(worktree, args);
323
+ fs.writeFileSync(path.join(worktree, 'feature.txt'), 'feature\n');
324
+ w(['add', 'feature.txt']);
325
+ fs.writeFileSync(path.join(worktree, 'README.md'), 'changed in the worktree\n');
326
+ const mainBefore = hostState(repo);
327
+ const worktreeBefore = hostState(worktree);
328
+
329
+ const snapshot = await snapshotOf(host, worktree);
330
+ expect(snapshot).toMatchObject({ branch: 'feature', repository: worktree });
331
+ expect(readTree(w, snapshot.staged)['feature.txt']).toBeDefined();
332
+ expect(blob(w, snapshot.start, 'README.md')).toBe('changed in the worktree\n');
333
+ expect(readTree(w, snapshot.start)['untracked plain.txt']).toBeUndefined();
334
+ expect(changedSince(mainBefore, repo)).toEqual([]);
335
+ expect(changedSince(worktreeBefore, worktree)).toEqual([]);
336
+ });
337
+
338
+ it('writes no shared index file into the repository of a user with a split index', async () => {
339
+ const host = createTestHost({ config: '[core]\n\tsplitIndex = true' });
340
+ const { repo, g } = makeBait(host);
341
+ g(['update-index', '--split-index']);
342
+ const before = hostState(repo);
343
+ expect(Object.keys(before).filter((file) => file.startsWith('.git/sharedindex.')).length).toBeGreaterThan(0);
344
+
345
+ const snapshot = await snapshotOf(host, repo);
346
+ expect(readTree(g, snapshot.start)['untracked plain.txt']).toBeDefined();
347
+ expect(blob(g, snapshot.staged, 'tracked-to-edit.txt')).toBe('one\ntwo staged\n');
348
+ expect(changedSince(before, repo)).toEqual([]);
349
+ });
350
+
351
+ it('brings the content of an intent-to-add file in the start, where it is new', async () => {
352
+ const host = createTestHost();
353
+ const { repo, g } = makeBait(host);
354
+ fs.writeFileSync(path.join(repo, 'intended.txt'), 'intended\n');
355
+ g(['add', '--intent-to-add', 'intended.txt']);
356
+ const before = hostState(repo);
357
+ const snapshot = await snapshotOf(host, repo);
358
+ expect(readTree(g, snapshot.staged)['intended.txt']).toBeUndefined();
359
+ expect(blob(g, snapshot.start, 'intended.txt')).toBe('intended\n');
360
+ expect(changedSince(before, repo)).toEqual([]);
361
+ });
362
+
363
+ it('reports a detached HEAD as no branch, and a project in a subfolder by its prefix', async () => {
364
+ const host = createTestHost();
365
+ const { repo, g } = makeBait(host);
366
+ fs.mkdirSync(path.join(repo, 'packages', 'app'), { recursive: true });
367
+ fs.writeFileSync(path.join(repo, 'packages', 'app', 'index.js'), 'app\n');
368
+ g(['checkout', '--quiet', '--detach']);
369
+ const snapshot = await snapshotOf(host, path.join(repo, 'packages', 'app'));
370
+ expect(snapshot).toMatchObject({ branch: null, prefix: 'packages/app/', repository: repo });
371
+ });
372
+ });
373
+
374
+ describe('listTravellingFiles', () => {
375
+ it('lists exactly what the snapshot adds, and untracked repositories as not travelling', async () => {
376
+ const host = createTestHost();
377
+ const { repo, g } = makeBait(host);
378
+ nestRepository(host, repo, 'nested');
379
+ nestRepository(host, repo, 'empty-nested', { commit: false });
380
+ const codeIn = host.codeIn();
381
+
382
+ const { files, notTravelling, totalBytes } = await codeIn.listTravellingFiles(repo);
383
+ const snapshot = await snapshotOf(host, repo);
384
+ const added = g(['diff-tree', '-r', '-z', '--no-renames', '--name-only', '--diff-filter=A', snapshot.staged, snapshot.start]).split('\0').filter(Boolean);
385
+ expect(files.map((file) => file.path).sort()).toEqual(added.sort());
386
+
387
+ expect(notTravelling).toEqual(expect.arrayContaining([{ path: 'nested', kind: 'repository' }, { path: 'empty-nested', kind: 'repository' }]));
388
+ expect(notTravelling).toHaveLength(2);
389
+ expect(files.map((file) => file.path).filter((file) => file.includes('nested'))).toEqual([]);
390
+ expect(files).toContainEqual({ path: 'untracked plain.txt', kind: 'file', size: Buffer.byteLength('untracked travels\n') });
391
+ expect(files.map((file) => file.path)).toEqual(expect.not.arrayContaining(['.env', 'secret-by-global-ignore.txt', 'node_modules/x.js']));
392
+ if (!WIN) expect(files).toContainEqual({ path: 'link-to-readme', kind: 'symlink', size: Buffer.byteLength('README.md') });
393
+ expect(totalBytes).toBe(files.reduce((sum, file) => sum + (file.size ?? 0), 0));
394
+ });
395
+ });
396
+
397
+ describe('readTransferLimits', () => {
398
+ it('names submodule paths and sees Git LFS', async () => {
399
+ const host = createTestHost();
400
+ const { repo, g } = makeBait(host);
401
+ expect(await host.codeIn().readTransferLimits(repo)).toEqual({ submodules: [], usesLfs: false });
402
+
403
+ const head = g(['rev-parse', 'HEAD']).trim();
404
+ g(['update-index', '--add', '--cacheinfo', `160000,${head},vendor/lib`]);
405
+ fs.writeFileSync(path.join(repo, '.gitattributes'), 'model.bin filter=lfs diff=lfs merge=lfs -text\n');
406
+ fs.writeFileSync(path.join(repo, 'model.bin'), 'weights\n');
407
+ expect(await host.codeIn().readTransferLimits(repo)).toEqual({ submodules: ['vendor/lib'], usesLfs: true });
408
+ });
409
+ });
410
+
411
+ describe('buildExtUrl', () => {
412
+ it('writes spaces and percent signs the way git-remote-ext reads them', () => {
413
+ expect(buildExtUrl(['C:\\Program Files\\Docker\\docker.exe', 'exec', '--interactive', '50%', 'a %s b'])).toBe(
414
+ 'ext::C:\\Program% Files\\Docker\\docker.exe exec --interactive 50%% a% %%s% b',
415
+ );
416
+ });
417
+
418
+ it.each([[[]], [['docker', '']], [['docker', 'a\nb']], [['docker', 'a\tb']], [['docker', 'a\0b']], [['docker', 'a\x7fb']]])('refuses what it cannot carry: %j', (argv) => {
419
+ expect(() => buildExtUrl(argv)).toThrow(SpaceError);
420
+ });
421
+ });
422
+
423
+ describe('sendHistory from a shallow host repository', () => {
424
+ it('reports that there is no history to send, and touches no space', async () => {
425
+ const host = createTestHost();
426
+ const { repo } = makeBait(host);
427
+ const shallow = path.join(host.root, 'shallow clone');
428
+ host.sh(host.root, ['clone', '--quiet', '--depth', '1', `file://${forConfig(repo)}`, shallow]);
429
+ const place = { exec: async () => { throw new Error('the space must not be asked'); }, execArgv: async () => { throw new Error('the space must not be asked'); } };
430
+ expect(await host.codeIn(place).sendHistory({ repository: shallow, spaceId: SPACE_ID, spacePath: `/spaces/${SPACE_ID}/shallow-clone`, base: host.sh(shallow, ['rev-parse', 'HEAD']).trim() })).toEqual({ status: 'host_shallow' });
431
+ });
432
+ });
433
+
434
+ describe('the git version floor', () => {
435
+ // The real host git, except that it answers `git version` with what the test says.
436
+ const gitAnswering = (host, text) => ({
437
+ run: (directory, args, options) => (args[args.length - 1] === 'version'
438
+ ? Promise.resolve({ code: 0, stdout: text, stderr: '' })
439
+ : host.git.run(directory, args, options)),
440
+ output: (directory, args, options) => host.git.output(directory, args, options),
441
+ });
442
+
443
+ it.each([
444
+ ['git version 2.26.2\n', 'git_too_old'],
445
+ ['git version 1.9.5\n', 'git_too_old'],
446
+ ['something else\n', 'git_version_unreadable'],
447
+ ])('refuses %j before it takes a snapshot', async (text, code) => {
448
+ const host = createTestHost();
449
+ const { repo } = makeBait(host);
450
+ const before = hostState(repo);
451
+ const codeIn = createCodeIn({ git: gitAnswering(host, text), place: null, temporaryDirectory: host.root });
452
+ const failure = await codeIn.takeSnapshot({ repository: repo, spaceId: SPACE_ID, mode: 'uncommitted' }).catch((error) => error);
453
+ expect(failure).toMatchObject({ code });
454
+ if (code === 'git_too_old') expect(failure.message).toMatch(/needs git 2\.27 or newer/);
455
+ expect(changedSince(before, repo, [])).toEqual([]);
456
+ });
457
+
458
+ it('lets 2.27.0 and every build suffix through, and refuses an old git for the history too', async () => {
459
+ const host = createTestHost();
460
+ const { repo } = makeBait(host);
461
+ for (const text of ['git version 2.27.0\n', 'git version 2.50.1 (Apple Git-155)\n', 'git version 2.54.0.windows.1\n']) {
462
+ const codeIn = createCodeIn({ git: gitAnswering(host, text), place: null, temporaryDirectory: host.root });
463
+ await codeIn.takeSnapshot({ repository: repo, spaceId: SPACE_ID, mode: 'clean' });
464
+ await codeIn.removeSpaceRefs({ repository: repo, spaceId: SPACE_ID });
465
+ }
466
+ const old = createCodeIn({ git: gitAnswering(host, 'git version 2.20.1\n'), place: null, temporaryDirectory: host.root });
467
+ await expect(old.sendHistory({ repository: repo, spaceId: SPACE_ID, spacePath: `/spaces/${SPACE_ID}/bait-repo-`, base: host.sh(repo, ['rev-parse', 'HEAD']).trim() }))
468
+ .rejects.toMatchObject({ code: 'history_transfer_failed', details: { cause: 'git_too_old' } });
469
+ });
470
+ });
471
+
472
+ describe('failures keep their codes', () => {
473
+ const unreachablePlace = { exec: async () => { throw new SpaceError('space_not_found', 'no such space'); }, execArgv: async () => { throw new SpaceError('space_not_found', 'no such space'); } };
474
+
475
+ it('reports a temporary folder that cannot be made with the documented codes, a step and a cause', async () => {
476
+ const host = createTestHost();
477
+ const { repo, g } = makeBait(host);
478
+ const nowhere = { temporaryDirectory: path.join(host.root, 'no such folder') };
479
+ await expect(host.codeIn(unreachablePlace, nowhere).bringCodeIn({ repository: repo, spaceId: SPACE_ID }))
480
+ .rejects.toMatchObject({ code: 'code_transfer_failed', details: { step: 'make a temporary folder', cause: 'ENOENT' } });
481
+ await expect(host.codeIn(unreachablePlace, nowhere).sendHistory({ repository: repo, spaceId: SPACE_ID, spacePath: `/spaces/${SPACE_ID}/bait-repo-`, base: g(['rev-parse', 'HEAD']).trim() }))
482
+ .rejects.toMatchObject({ code: 'history_transfer_failed', details: { step: 'make a temporary folder', cause: 'ENOENT' } });
483
+ });
484
+
485
+ it.each([
486
+ ['a repository moved away', (host) => ({ repository: path.join(host.root, 'moved away') }), 'project_folder_missing'],
487
+ ['a folder that is no repository', (host) => { const plain = path.join(host.root, 'plain'); fs.mkdirSync(plain, { recursive: true }); return { repository: plain }; }, 'not_a_git_work_tree'],
488
+ ['a space that cannot be reached', () => ({}), 'space_not_found'],
489
+ ['a space path code in did not return', () => ({ spacePath: `/spaces/${SPACE_ID}/../elsewhere` }), 'invalid_space_path'],
490
+ ])('rejects the history with history_transfer_failed for %s, with the cause', async (_, change, cause) => {
491
+ const host = createTestHost();
492
+ const { repo, g } = makeBait(host);
493
+ const request = { repository: repo, spaceId: SPACE_ID, spacePath: `/spaces/${SPACE_ID}/bait-repo-`, base: g(['rev-parse', 'HEAD']).trim(), ...change(host) };
494
+ const failure = await host.codeIn(unreachablePlace).sendHistory(request).catch((error) => error);
495
+ expect(failure).toMatchObject({ code: 'history_transfer_failed', details: { cause } });
496
+ expect(failure.details.step).toEqual(expect.any(String));
497
+ });
498
+
499
+ it('turns a failure of the place into code_transfer_failed, with the place\'s code as the cause', async () => {
500
+ const host = createTestHost();
501
+ const { repo, g } = makeBait(host);
502
+ const before = hostState(repo);
503
+ const failure = await host.codeIn(unreachablePlace).bringCodeIn({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
504
+ expect(failure).toMatchObject({ code: 'code_transfer_failed', details: { step: 'reach the space', cause: 'space_not_found' } });
505
+ expect(g(['for-each-ref', 'refs/openchamber/'])).toBe('');
506
+ expect(changedSince(before, repo)).toEqual([]);
507
+ });
508
+
509
+ it('turns a runner failure during the snapshot into code_transfer_failed, and passes the refusals as they are', async () => {
510
+ const host = createTestHost();
511
+ const { repo } = makeBait(host);
512
+ const timingOut = {
513
+ run: (directory, args, options) => host.git.run(directory, args, options),
514
+ output: (directory, args, options) => (args.includes('add')
515
+ ? Promise.reject(new SpaceError('command_timeout', 'git add did not finish'))
516
+ : host.git.output(directory, args, options)),
517
+ };
518
+ await expect(createCodeIn({ git: timingOut, place: unreachablePlace, temporaryDirectory: host.root }).bringCodeIn({ repository: repo, spaceId: SPACE_ID }))
519
+ .rejects.toMatchObject({ code: 'code_transfer_failed', details: { step: 'take the snapshot', cause: 'command_timeout' } });
520
+ await expect(host.codeIn(unreachablePlace).bringCodeIn({ repository: repo, spaceId: SPACE_ID, mode: 'neither' }))
521
+ .rejects.toMatchObject({ code: 'invalid_snapshot_mode' });
522
+ });
523
+
524
+ it('asks the place for the argv before anything runs inside, so a stopped space is refused cleanly', async () => {
525
+ const host = createTestHost();
526
+ const { repo, g } = makeBait(host);
527
+ const ranInside = [];
528
+ const stopped = {
529
+ execArgv: async () => { throw new SpaceError('space_not_running', 'Space is stopped. Start it, then try again.'); },
530
+ exec: async (spaceId, argv) => { ranInside.push(argv); return { code: 1, stdout: '', stderr: 'container is not running' }; },
531
+ };
532
+ await expect(host.codeIn(stopped).bringCodeIn({ repository: repo, spaceId: SPACE_ID }))
533
+ .rejects.toMatchObject({ code: 'code_transfer_failed', message: expect.stringMatching(/is stopped/), details: { cause: 'space_not_running' } });
534
+ await expect(host.codeIn(stopped).sendHistory({ repository: repo, spaceId: SPACE_ID, spacePath: `/spaces/${SPACE_ID}/bait-repo-`, base: g(['rev-parse', 'HEAD']).trim() }))
535
+ .rejects.toMatchObject({ code: 'history_transfer_failed', details: { cause: 'space_not_running' } });
536
+ expect(ranInside).toEqual([]);
537
+ });
538
+
539
+ it('rejects a missing request with a SpaceError, not a TypeError', async () => {
540
+ const host = createTestHost();
541
+ const codeIn = host.codeIn(unreachablePlace);
542
+ await expect(codeIn.bringCodeIn(undefined)).rejects.toMatchObject({ code: 'invalid_space_id' });
543
+ await expect(codeIn.takeSnapshot(undefined)).rejects.toMatchObject({ code: 'invalid_space_id' });
544
+ await expect(codeIn.removeSpaceRefs(undefined)).rejects.toMatchObject({ code: 'invalid_space_id' });
545
+ await expect(codeIn.sendHistory(undefined)).rejects.toMatchObject({ code: 'history_transfer_failed', details: { cause: 'invalid_space_id' } });
546
+ await expect(codeIn.sendHistory(null)).rejects.toMatchObject({ code: 'history_transfer_failed', details: { cause: 'invalid_space_id' } });
547
+ });
548
+
549
+ it('refuses a time limit that is not a whole number of milliseconds in range', async () => {
550
+ const host = createTestHost();
551
+ const { repo, g } = makeBait(host);
552
+ const codeIn = host.codeIn(unreachablePlace);
553
+ for (const timeoutMs of ['60000', Number.NaN, 0, 999, 1500.5, 86_400_001]) {
554
+ await expect(codeIn.bringCodeIn({ repository: repo, spaceId: SPACE_ID, timeoutMs })).rejects.toMatchObject({ code: 'invalid_timeout' });
555
+ await expect(codeIn.sendHistory({ repository: repo, spaceId: SPACE_ID, spacePath: `/spaces/${SPACE_ID}/bait-repo-`, base: g(['rev-parse', 'HEAD']).trim(), timeoutMs }))
556
+ .rejects.toMatchObject({ code: 'history_transfer_failed', details: { cause: 'invalid_timeout' } });
557
+ }
558
+ expect(g(['for-each-ref', 'refs/openchamber/'])).toBe('');
559
+ });
560
+
561
+ // An inherited GIT_LITERAL_PATHSPECS would read `:(exclude,literal)<dir>` as a path, and `add` would
562
+ // fail, and `:(attr:filter=lfs)` would match nothing.
563
+ it('keeps working for a user whose environment sets GIT_LITERAL_PATHSPECS', async () => {
564
+ const host = createTestHost();
565
+ const { repo, g } = makeBait(host);
566
+ nestRepository(host, repo, 'nested');
567
+ fs.writeFileSync(path.join(repo, '.gitattributes'), 'model.bin filter=lfs diff=lfs merge=lfs -text\n');
568
+ fs.writeFileSync(path.join(repo, 'model.bin'), 'weights\n');
569
+ const git = createHostGit({ runCommand, environment: { ...host.environment, GIT_LITERAL_PATHSPECS: '1' } });
570
+ const codeIn = createCodeIn({ git, place: null, temporaryDirectory: host.root });
571
+ const snapshot = await codeIn.takeSnapshot({ repository: repo, spaceId: SPACE_ID, mode: 'uncommitted' });
572
+ expect(Object.keys(readTree(g, snapshot.start)).filter((file) => file.startsWith('nested'))).toEqual([]);
573
+ expect((await codeIn.readTransferLimits(repo)).usesLfs).toBe(true);
574
+ });
575
+
576
+ it('says a folder that does not exist does not exist', async () => {
577
+ const host = createTestHost();
578
+ await expect(host.codeIn().takeSnapshot({ repository: path.join(host.root, 'not here'), spaceId: SPACE_ID, mode: 'uncommitted' }))
579
+ .rejects.toMatchObject({ code: 'project_folder_missing', message: expect.stringMatching(/does not exist/) });
580
+ });
581
+
582
+ it('gives the plain message for a git that cannot be started, and the history keeps it as its cause', async () => {
583
+ const host = createTestHost();
584
+ const { repo, g } = makeBait(host);
585
+ const missing = createHostGit({ runCommand, gitPath: path.join(host.root, 'no-such-git'), environment: host.environment });
586
+ const codeIn = createCodeIn({ git: missing, place: unreachablePlace, temporaryDirectory: host.root });
587
+ await expect(codeIn.bringCodeIn({ repository: repo, spaceId: SPACE_ID }))
588
+ .rejects.toMatchObject({ code: 'git_version_unreadable', details: { cause: 'command_spawn_failed' } });
589
+ await expect(codeIn.sendHistory({ repository: repo, spaceId: SPACE_ID, spacePath: `/spaces/${SPACE_ID}/bait-repo-`, base: g(['rev-parse', 'HEAD']).trim() }))
590
+ .rejects.toMatchObject({ code: 'history_transfer_failed', details: { cause: 'git_version_unreadable' } });
591
+ });
592
+
593
+ it('keeps the error of the work when the temporary folder cannot be removed, and names the folder', async () => {
594
+ const host = createTestHost();
595
+ const { repo, g } = makeBait(host);
596
+ const before = hostState(repo);
597
+ const stuck = { removeDirectory: async () => { throw Object.assign(new Error('resource busy or locked'), { code: 'EBUSY' }); } };
598
+ const failure = await host.codeIn(unreachablePlace, stuck).bringCodeIn({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
599
+ expect(failure).toMatchObject({ code: 'code_transfer_failed', details: { step: 'reach the space', cause: 'space_not_found' } });
600
+ expect(failure.details.temporaryDirectoryLeft).toEqual(expect.stringContaining('openchamber-code-in-'));
601
+ expect(g(['for-each-ref', 'refs/openchamber/'])).toBe('');
602
+ expect(changedSince(before, repo)).toEqual([]);
603
+ // After a call that worked, the leftover folder does not turn it into a failure.
604
+ expect(await host.codeIn(null, stuck).listTravellingFiles(repo)).toMatchObject({ files: expect.any(Array) });
605
+ });
606
+ });
607
+
608
+ // The receiving side of a push, standing in for `docker exec ... git receive-pack` in a real space.
609
+ // It checks that the host wrote out the fixed command, and maps /spaces/ into a local folder.
610
+ const RECEIVER = `
611
+ const { spawn } = require('node:child_process');
612
+ const fs = require('node:fs');
613
+ const path = require('node:path');
614
+ const [root, behaviour, ...command] = process.argv.slice(2);
615
+ const [timeoutPath, dashS, signal, seconds, gitPath, service, target] = command;
616
+ if (command.length !== 7 || timeoutPath !== '/usr/bin/timeout' || dashS !== '-s' || signal !== 'KILL' || !/^[0-9]+$/.test(seconds)
617
+ || gitPath !== '/usr/bin/git' || service !== 'receive-pack' || !target.startsWith('/spaces/')) {
618
+ process.stderr.write('unexpected command ' + JSON.stringify(command) + '\\n');
619
+ process.exit(2);
620
+ }
621
+ fs.writeFileSync(path.join(root, 'last-command.json'), JSON.stringify(command));
622
+ fs.appendFileSync(path.join(root, 'commands.log'), target + '\\n');
623
+ if (behaviour === 'refuse') { process.stderr.write('refused by the space\\n'); process.exit(1); }
624
+ if (behaviour === 'flood') {
625
+ const chunk = 'x'.repeat(65536);
626
+ const pump = () => { while (process.stderr.write(chunk)) {} process.stderr.once('drain', pump); };
627
+ pump();
628
+ } else if (behaviour === 'hang') {
629
+ const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
630
+ fs.writeFileSync(path.join(root, 'hang.pid'), String(grandchild.pid));
631
+ setInterval(() => {}, 1000);
632
+ } else {
633
+ const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')));
634
+ const child = spawn('git', ['receive-pack', path.join(root, target)], { stdio: 'inherit', env: { ...env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: ${JSON.stringify(os.devNull)} } });
635
+ child.on('exit', (code) => process.exit(code ?? 1));
636
+ }
637
+ `;
638
+
639
+ /**
640
+ * A stand-in place for one space in a local folder. `exec` runs the module's fixed scripts with the
641
+ * host's `sh` and `git`, with /spaces/ mapped into the folder and a `timeout` that only drops its
642
+ * limit, since the host may have none. `execArgv` points at RECEIVER, kept in a folder whose name has
643
+ * a space and a percent sign, so the ext escaping is exercised through git itself.
644
+ */
645
+ function createLocalPlace(host, behaviour = 'receive') {
646
+ const root = path.join(host.root, 'stand in 50% place');
647
+ const bin = path.join(root, 'bin');
648
+ fs.mkdirSync(path.join(root, 'spaces', SPACE_ID), { recursive: true });
649
+ fs.mkdirSync(bin);
650
+ const receiver = path.join(root, 'receiver.cjs');
651
+ fs.writeFileSync(receiver, RECEIVER);
652
+ fs.writeFileSync(path.join(bin, 'timeout'), '#!/bin/sh\nshift 3\nexec "$@"\n', { mode: 0o755 });
653
+ const emptyConfig = path.join(root, 'inside-gitconfig');
654
+ fs.writeFileSync(emptyConfig, '');
655
+ const insideEnvironment = { PATH: `${bin}${path.delimiter}${process.env.PATH}`, HOME: root, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: emptyConfig };
656
+ const local = (inside) => path.join(root, inside);
657
+ return {
658
+ root,
659
+ local,
660
+ inside: (args) => {
661
+ const result = spawnSync('git', args, { env: insideEnvironment, encoding: 'utf8' });
662
+ return { code: result.status, stdout: result.stdout };
663
+ },
664
+ lastCommand: () => JSON.parse(fs.readFileSync(path.join(root, 'last-command.json'), 'utf8')),
665
+ /** How many pushes reached the history side repository. */
666
+ historyPushes: () => (fs.existsSync(path.join(root, 'commands.log')) ? fs.readFileSync(path.join(root, 'commands.log'), 'utf8').split('\n').filter((line) => line.endsWith('.openchamber-history.git')).length : 0),
667
+ hangPid: () => Number(fs.readFileSync(path.join(root, 'hang.pid'), 'utf8')),
668
+ place: {
669
+ execArgv: async (spaceId) => {
670
+ if (spaceId !== SPACE_ID) throw new SpaceError('space_not_found', 'no such space');
671
+ return [process.execPath, receiver, path.join(root), behaviour];
672
+ },
673
+ exec: async (spaceId, argv, { timeoutMs }) => {
674
+ const [shell, flag, script, zero, ...args] = argv;
675
+ expect([shell, flag, zero]).toEqual([IMAGE_SH, '-c', 'sh']);
676
+ expect(script.startsWith(IMAGE_ONLY_PATH)).toBe(true);
677
+ // Quoted: the stand-in's folder name has a space in it, which the image's own PATH never has.
678
+ const localScript = `PATH='${insideEnvironment.PATH}';${script.slice(IMAGE_ONLY_PATH.length)}`;
679
+ return runCommand('/bin/sh', ['-c', localScript, zero, ...args.map((arg) => (arg.startsWith('/spaces/') ? local(arg) : arg))], { env: insideEnvironment, timeoutMs });
680
+ },
681
+ },
682
+ };
683
+ }
684
+
685
+ // Every hook that git on the host could run during code in, each writing a marker of its own name.
686
+ const HOOK_NAMES = ['pre-push', 'post-index-change', 'reference-transaction', 'post-checkout', 'pre-commit', 'post-commit', 'pre-auto-gc', 'post-rewrite', 'push-to-checkout', 'pre-receive', 'post-receive', 'update', 'post-update'];
687
+
688
+ describe.skipIf(WIN)('transfer into a local stand-in for a space', () => {
689
+ it('brings the bait in as staged and unstaged changes on the same branch, with the identity, and nothing ignored', async () => {
690
+ const host = createTestHost();
691
+ const { repo, g } = makeBait(host);
692
+ const local = createLocalPlace(host);
693
+ const before = hostState(repo);
694
+ const result = await host.codeIn(local.place).bringCodeIn({ repository: repo, spaceId: SPACE_ID, timeoutMs: 60_000 });
695
+
696
+ expect(result.spacePath).toBe(`/spaces/${SPACE_ID}/bait-repo-`);
697
+ expect(result.projectPath).toBe(result.spacePath);
698
+ expect(result.identityCopied).toEqual({ name: true, email: true });
699
+ const inside = local.local(result.spacePath);
700
+ const gi = (args) => local.inside(['-C', inside, ...args]);
701
+ expect(gi(['--no-optional-locks', 'status', '--porcelain=v1', '--untracked-files=all']).stdout).toBe(shortStatus(g));
702
+ expect(gi(['symbolic-ref', 'HEAD']).stdout).toBe('refs/heads/main\n');
703
+ expect(gi(['rev-parse', 'HEAD']).stdout.trim()).toBe(result.base);
704
+ expect(gi(['rev-parse', '--is-shallow-repository']).stdout).toBe('true\n');
705
+ expect(gi(['config', 'user.name']).stdout).toBe('Bait Author\n');
706
+ expect(gi(['config', 'user.email']).stdout).toBe('bait@example.invalid\n');
707
+ // The repository starts on the host's branch: no stray `master` in the agent's reflog.
708
+ expect(gi(['reflog', '--format=%gs']).stdout).not.toContain('master');
709
+ expect(fs.statSync(path.join(inside, 'run.sh')).mode & 0o111).not.toBe(0);
710
+ for (const absent of ['.env', 'node_modules', 'secret-by-global-ignore.txt']) {
711
+ expect(fs.existsSync(path.join(inside, absent))).toBe(false);
712
+ }
713
+ // The inner limit is the host's limit plus a margin, never less.
714
+ expect(Number(local.lastCommand()[3])).toBe(65);
715
+ expect(changedSince(before, repo)).toEqual([]);
716
+ expect(g(['rev-parse', `refs/openchamber/spaces/${SPACE_ID}/start`]).trim()).toBe(result.start);
717
+
718
+ expect(await host.codeIn(local.place).sendHistory({ repository: repo, spaceId: SPACE_ID, spacePath: result.spacePath, base: result.base })).toEqual({ status: 'sent' });
719
+ expect(gi(['rev-list', '--count', 'HEAD']).stdout).toBe(g(['rev-list', '--count', 'HEAD']));
720
+ expect(gi(['rev-parse', '--is-shallow-repository']).stdout).toBe('false\n');
721
+ expect(fs.existsSync(local.local(`/spaces/${SPACE_ID}/.openchamber-history.git`))).toBe(false);
722
+ expect(gi(['--no-optional-locks', 'status', '--porcelain=v1', '--untracked-files=all']).stdout).toBe(shortStatus(g));
723
+ expect(changedSince(before, repo)).toEqual([]);
724
+ });
725
+
726
+ it.each([
727
+ ['no identity', { identity: false }, '', { name: false, email: false }],
728
+ ['a name only', { identity: false }, '[user]\n\tname = Only A Name', { name: true, email: false }],
729
+ ])('reports which parts of the identity it copied for a user with %s, and guesses nothing', async (_, options, config, copied) => {
730
+ const host = createTestHost(options);
731
+ const { repo } = makeBait(host);
732
+ if (config) host.addConfig(config);
733
+ const local = createLocalPlace(host);
734
+ const result = await host.codeIn(local.place).bringCodeIn({ repository: repo, spaceId: SPACE_ID, timeoutMs: 60_000 });
735
+ expect(result.identityCopied).toEqual(copied);
736
+ const gi = (args) => local.inside(['-C', local.local(result.spacePath), ...args]);
737
+ expect(gi(['config', '--local', 'user.email']).code).not.toBe(0);
738
+ expect(gi(['config', '--local', 'user.name']).stdout).toBe(copied.name ? 'Only A Name\n' : '');
739
+ });
740
+
741
+ it('leaves out untracked repositories, so the start and the working tree inside agree', async () => {
742
+ const host = createTestHost();
743
+ const { repo, g } = makeBait(host);
744
+ nestRepository(host, repo, 'nested');
745
+ nestRepository(host, repo, ODD_NAME, { commit: false });
746
+ const local = createLocalPlace(host);
747
+ const result = await host.codeIn(local.place).bringCodeIn({ repository: repo, spaceId: SPACE_ID, timeoutMs: 60_000 });
748
+ const inside = local.local(result.spacePath);
749
+ // What the space's working tree holds, as a tree, the way a 3b snapshot inside would see it.
750
+ const index = path.join(local.root, 'agree-index');
751
+ const withIndex = (args) => spawnSync('git', ['-C', inside, ...args], { env: { ...process.env, GIT_INDEX_FILE: index, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: os.devNull }, encoding: 'utf8' });
752
+ withIndex(['read-tree', 'refs/openchamber/start']);
753
+ withIndex(['add', '--all']);
754
+ expect(withIndex(['write-tree']).stdout.trim()).toBe(g(['rev-parse', `${result.start}^{tree}`]).trim());
755
+ expect(fs.existsSync(path.join(inside, 'nested'))).toBe(false);
756
+ });
757
+
758
+ it('runs none of the user\'s hooks on the host, in the snapshot, the ref writes, the pushes or the history', async () => {
759
+ const host = createTestHost();
760
+ const hooks = path.join(host.root, 'global hooks');
761
+ const markers = path.join(host.root, 'markers');
762
+ fs.mkdirSync(hooks);
763
+ fs.mkdirSync(markers);
764
+ for (const name of HOOK_NAMES) {
765
+ fs.writeFileSync(path.join(hooks, name), `#!/bin/sh\ntouch '${markers}/${name}'\ncat > /dev/null 2>&1 || true\n`, { mode: 0o755 });
766
+ }
767
+ host.addConfig(`[core]\n\thooksPath = ${forConfig(hooks)}`);
768
+ const { repo, g } = makeBait(host);
769
+
770
+ // The control, in a copy: with this config, an index write, a ref write and a push run the hooks.
771
+ const control = makeBait(host, { name: 'control' });
772
+ control.g(['add', 'untracked plain.txt']);
773
+ control.g(['update-ref', 'refs/heads/control', 'HEAD']);
774
+ const bare = path.join(host.root, 'control.git');
775
+ host.sh(host.root, ['init', '--quiet', '--bare', bare]);
776
+ control.g(['push', '--quiet', bare, 'HEAD:refs/heads/control']);
777
+ expect(fs.readdirSync(markers).sort()).toEqual(expect.arrayContaining(['post-index-change', 'pre-push', 'reference-transaction']));
778
+ for (const marker of fs.readdirSync(markers)) fs.rmSync(path.join(markers, marker));
779
+
780
+ const codeIn = host.codeIn(createLocalPlace(host).place);
781
+ const result = await codeIn.bringCodeIn({ repository: repo, spaceId: SPACE_ID, timeoutMs: 60_000 });
782
+ await codeIn.sendHistory({ repository: repo, spaceId: SPACE_ID, spacePath: result.spacePath, base: result.base });
783
+ await codeIn.listTravellingFiles(repo);
784
+ await codeIn.readTransferLimits(repo);
785
+ await codeIn.removeSpaceRefs({ repository: repo, spaceId: SPACE_ID });
786
+ expect(fs.readdirSync(markers)).toEqual([]);
787
+ expect(g(['for-each-ref', 'refs/openchamber/'])).toBe('');
788
+ });
789
+
790
+ /** A whole code in, the history, both lists and the ref removal, for the tests that watch what it starts. */
791
+ const everything = async (host, repo) => {
792
+ const codeIn = host.codeIn(createLocalPlace(host).place);
793
+ const result = await codeIn.bringCodeIn({ repository: repo, spaceId: SPACE_ID, timeoutMs: 60_000 });
794
+ await codeIn.sendHistory({ repository: repo, spaceId: SPACE_ID, spacePath: result.spacePath, base: result.base });
795
+ await codeIn.listTravellingFiles(repo);
796
+ await codeIn.readTransferLimits(repo);
797
+ await codeIn.removeSpaceRefs({ repository: repo, spaceId: SPACE_ID });
798
+ };
799
+
800
+ it('runs no fsmonitor program of the user\'s', async () => {
801
+ const host = createTestHost();
802
+ const marker = path.join(host.root, 'fsmonitor-ran');
803
+ const program = path.join(host.root, 'fsmonitor.sh');
804
+ fs.writeFileSync(program, `#!/bin/sh\necho "$GIT_INDEX_FILE" >> '${marker}'\nexit 1\n`, { mode: 0o755 });
805
+ host.addConfig(`[core]\n\tfsmonitor = ${forConfig(program)}`);
806
+ const { repo } = makeBait(host);
807
+ // The control, in a copy: with this config a status runs the program.
808
+ const control = makeBait(host, { name: 'control' });
809
+ control.g(['status', '--porcelain']);
810
+ expect(fs.existsSync(marker)).toBe(true);
811
+ fs.rmSync(marker);
812
+ const before = hostState(repo);
813
+
814
+ await everything(host, repo);
815
+ expect(fs.existsSync(marker)).toBe(false);
816
+ expect(changedSince(before, repo)).toEqual([]);
817
+ });
818
+
819
+ // The built-in daemon exists on macOS and Windows builds of git, not on Linux.
820
+ it.skipIf(process.platform === 'linux')('starts no fsmonitor daemon for a user with core.fsmonitor=true, and leaves none of its files', async () => {
821
+ const host = createTestHost();
822
+ host.addConfig('[core]\n\tfsmonitor = true');
823
+ const { repo } = makeBait(host);
824
+ const control = makeBait(host, { name: 'control' });
825
+ const daemonRuns = (directory) => spawnSync('git', ['-C', directory, 'fsmonitor--daemon', 'status'], { env: host.environment, windowsHide: true }).status === 0;
826
+ const stop = (directory) => spawnSync('git', ['-C', directory, 'fsmonitor--daemon', 'stop'], { env: host.environment, windowsHide: true });
827
+ try {
828
+ // The control: with this config a status starts the daemon.
829
+ control.g(['status', '--porcelain']);
830
+ expect(daemonRuns(control.repo)).toBe(true);
831
+ const before = hostState(repo);
832
+ await everything(host, repo);
833
+ expect(daemonRuns(repo)).toBe(false);
834
+ expect(changedSince(before, repo)).toEqual([]);
835
+ } finally {
836
+ stop(control.repo);
837
+ stop(repo);
838
+ }
839
+ });
840
+
841
+ // None of our commands runs `gc --auto` or maintenance, which git starts after commit, merge,
842
+ // fetch, am and rebase. A gc here would repack, and the comparison would see a new pack.
843
+ it('starts no automatic gc or maintenance, even for a user who asks for them at every chance', async () => {
844
+ const host = createTestHost();
845
+ // Two packs, one more than `gc.autoPackLimit` allows below, so any `gc --auto` consolidates them.
846
+ const packs = (repo) => fs.readdirSync(path.join(repo, '.git', 'objects', 'pack')).filter((file) => file.endsWith('.pack')).length;
847
+ const twoPacks = ({ repo, g }) => {
848
+ g(['repack', '-q', '-d']);
849
+ g(['commit', '--quiet', '--allow-empty', '-m', 'a second pack']);
850
+ g(['repack', '-q', '-d']);
851
+ expect(packs(repo)).toBe(2);
852
+ };
853
+ const bait = makeBait(host);
854
+ const control = makeBait(host, { name: 'control' });
855
+ twoPacks(bait);
856
+ twoPacks(control);
857
+ // Everything in the foreground and pinned to the gc task. A detached gc or maintenance would
858
+ // consolidate the packs after the test looked, and the control and the main assertion alike would
859
+ // look too early; measured in CI with git 2.55.0, the control saw two packs. Newer git also knows
860
+ // other maintenance strategies, so the strategy and the task are named rather than left to a default.
861
+ host.addConfig([
862
+ '[gc]', '\tauto = 1', '\tautoDetach = false', '\tautoPackLimit = 1',
863
+ '[maintenance]', '\tauto = true', '\tautoDetach = false', '\tstrategy = gc',
864
+ '[maintenance "gc"]', '\tenabled = true',
865
+ ].join('\n'));
866
+ // The control, in the copy: with this config an ordinary commit consolidates the packs. Plain
867
+ // git here, not `control.g`: the setup git opts out of maintenance, which is what the control proves.
868
+ const commit = spawnSync('git', ['-C', control.repo, 'commit', '--quiet', '--allow-empty', '-m', 'control'], { env: host.environment, encoding: 'utf8', windowsHide: true });
869
+ expect(commit.status, commit.stderr).toBe(0);
870
+ expect(packs(control.repo)).toBe(1);
871
+ const before = hostState(bait.repo);
872
+ await everything(host, bait.repo);
873
+ expect(packs(bait.repo)).toBe(2);
874
+ expect(changedSince(before, bait.repo)).toEqual([]);
875
+ });
876
+
877
+ it('sends the history into the path code in returned, even after the host folder was renamed', async () => {
878
+ const host = createTestHost();
879
+ const { repo } = makeBait(host);
880
+ const local = createLocalPlace(host);
881
+ const codeIn = host.codeIn(local.place);
882
+ const result = await codeIn.bringCodeIn({ repository: repo, spaceId: SPACE_ID, timeoutMs: 60_000 });
883
+ const renamed = path.join(host.root, 'renamed checkout');
884
+ fs.renameSync(repo, renamed);
885
+ expect(await codeIn.sendHistory({ repository: renamed, spaceId: SPACE_ID, spacePath: result.spacePath, base: result.base })).toEqual({ status: 'sent' });
886
+ expect(local.inside(['-C', local.local(result.spacePath), 'rev-parse', '--is-shallow-repository']).stdout).toBe('false\n');
887
+ });
888
+
889
+ it('shares one run between calls for the same space, and sends nothing again once the history is there', async () => {
890
+ const host = createTestHost();
891
+ const { repo, g } = makeBait(host);
892
+ const local = createLocalPlace(host);
893
+ const codeIn = host.codeIn(local.place);
894
+ const result = await codeIn.bringCodeIn({ repository: repo, spaceId: SPACE_ID, timeoutMs: 60_000 });
895
+ const request = { repository: repo, spaceId: SPACE_ID, spacePath: result.spacePath, base: result.base };
896
+ // A retry while the first run is still going: the side repository is one per space.
897
+ expect(await Promise.all([codeIn.sendHistory(request), codeIn.sendHistory(request)])).toEqual([{ status: 'sent' }, { status: 'sent' }]);
898
+ expect(local.historyPushes()).toBe(1);
899
+ // Afterwards the repository is complete, and a call says so and pushes nothing.
900
+ expect(await codeIn.sendHistory(request)).toEqual({ status: 'already_complete' });
901
+ expect(local.historyPushes()).toBe(1);
902
+ expect(local.inside(['-C', local.local(result.spacePath), 'rev-list', '--count', 'HEAD']).stdout).toBe(g(['rev-list', '--count', 'HEAD']));
903
+ });
904
+
905
+ it('checks each call before it joins a run, refuses one that asks for something else, and shares across instances', async () => {
906
+ const host = createTestHost();
907
+ const { repo, g } = makeBait(host);
908
+ const local = createLocalPlace(host);
909
+ const first = host.codeIn(local.place);
910
+ const second = host.codeIn(local.place);
911
+ const result = await first.bringCodeIn({ repository: repo, spaceId: SPACE_ID, timeoutMs: 60_000 });
912
+ const request = { repository: repo, spaceId: SPACE_ID, spacePath: result.spacePath, base: result.base };
913
+ const running = first.sendHistory(request);
914
+ const joined = second.sendHistory(request);
915
+ const badPath = second.sendHistory({ ...request, spacePath: '/elsewhere', base: 'not a commit' }).catch((error) => error);
916
+ const otherPath = second.sendHistory({ ...request, spacePath: `/spaces/${SPACE_ID}/other` }).catch((error) => error);
917
+ const otherBase = second.sendHistory({ ...request, base: g(['rev-parse', 'HEAD~1']).trim() }).catch((error) => error);
918
+ const otherCheckout = second.sendHistory({ ...request, repository: path.join(host.root, 'another checkout') }).catch((error) => error);
919
+ expect(await Promise.all([running, joined])).toEqual([{ status: 'sent' }, { status: 'sent' }]);
920
+ expect(local.historyPushes()).toBe(1);
921
+ expect(await badPath).toMatchObject({ code: 'history_transfer_failed', details: { step: 'check the request', cause: 'invalid_space_path' } });
922
+ expect(await otherPath).toMatchObject({ code: 'history_transfer_failed', details: { cause: 'history_in_progress' } });
923
+ expect(await otherBase).toMatchObject({ code: 'history_transfer_failed', details: { cause: 'history_in_progress' } });
924
+ expect(await otherCheckout).toMatchObject({ code: 'history_transfer_failed', details: { cause: 'history_in_progress' } });
925
+ });
926
+
927
+ it('puts the margin it is given between the host limit and the limit inside, and refuses one that is not a length', async () => {
928
+ const host = createTestHost();
929
+ const { repo, g } = makeBait(host);
930
+ const local = createLocalPlace(host);
931
+ const codeIn = host.codeIn(local.place);
932
+ const result = await codeIn.bringCodeIn({ repository: repo, spaceId: SPACE_ID, timeoutMs: 60_000, innerMarginSeconds: 9 });
933
+ expect(Number(local.lastCommand()[3])).toBe(69);
934
+ for (const innerMarginSeconds of [0, -1, 1.5, 3601, '9']) {
935
+ await expect(codeIn.bringCodeIn({ repository: repo, spaceId: OTHER_SPACE_ID, innerMarginSeconds })).rejects.toMatchObject({ code: 'invalid_inner_margin' });
936
+ await expect(codeIn.sendHistory({ repository: repo, spaceId: SPACE_ID, spacePath: result.spacePath, base: result.base, innerMarginSeconds }))
937
+ .rejects.toMatchObject({ code: 'history_transfer_failed', details: { cause: 'invalid_inner_margin' } });
938
+ }
939
+ expect(g(['for-each-ref', `refs/openchamber/spaces/${OTHER_SPACE_ID}/`])).toBe('');
940
+ });
941
+
942
+ it('brings a SHA-256 repository into a SHA-256 repository', async () => {
943
+ const host = createTestHost();
944
+ const { repo, g } = makeBait(host, { objectFormat: 'sha256' });
945
+ const local = createLocalPlace(host);
946
+ const result = await host.codeIn(local.place).bringCodeIn({ repository: repo, spaceId: SPACE_ID, timeoutMs: 60_000 });
947
+ const gi = (args) => local.inside(['-C', local.local(result.spacePath), ...args]);
948
+ expect(gi(['rev-parse', '--show-object-format']).stdout).toBe('sha256\n');
949
+ expect(gi(['--no-optional-locks', 'status', '--porcelain=v1', '--untracked-files=all']).stdout).toBe(shortStatus(g));
950
+ });
951
+
952
+ it('brings a linked worktree in with its own branch, and a detached HEAD as a detached HEAD', async () => {
953
+ const host = createTestHost();
954
+ const { repo, g } = makeBait(host);
955
+ const worktree = path.join(host.root, 'linked');
956
+ g(['worktree', 'add', '--quiet', '-b', 'feature', worktree]);
957
+ fs.writeFileSync(path.join(worktree, 'feature.txt'), 'feature\n');
958
+ const local = createLocalPlace(host);
959
+ const result = await host.codeIn(local.place).bringCodeIn({ repository: worktree, spaceId: SPACE_ID, timeoutMs: 60_000 });
960
+ const gi = (args) => local.inside(['-C', local.local(result.spacePath), ...args]);
961
+ expect(result.spacePath).toBe(`/spaces/${SPACE_ID}/linked`);
962
+ expect(gi(['symbolic-ref', 'HEAD']).stdout).toBe('refs/heads/feature\n');
963
+ expect(gi(['--no-optional-locks', 'status', '--porcelain=v1', '--untracked-files=all']).stdout).toBe(shortStatus((args) => host.sh(worktree, args)));
964
+
965
+ const detachedHost = createTestHost();
966
+ const detached = makeBait(detachedHost);
967
+ detached.g(['checkout', '--quiet', '--detach']);
968
+ const detachedLocal = createLocalPlace(detachedHost);
969
+ const detachedResult = await detachedHost.codeIn(detachedLocal.place).bringCodeIn({ repository: detached.repo, spaceId: SPACE_ID, timeoutMs: 60_000 });
970
+ const detachedInside = detachedLocal.inside(['-C', detachedLocal.local(detachedResult.spacePath), 'symbolic-ref', '--quiet', 'HEAD']);
971
+ expect(detachedInside.code).not.toBe(0);
972
+ });
973
+
974
+ it.each([
975
+ ['refuses', 'refuse', null, 'refused by the space'],
976
+ ['prints far more than a push does', 'flood', 'command_output_too_large', ''],
977
+ ])('leaves the host repository as it was and removes its ref when the space %s', async (_, behaviour, cause, printed) => {
978
+ const host = createTestHost();
979
+ const { repo, g } = makeBait(host);
980
+ const local = createLocalPlace(host, behaviour);
981
+ const before = hostState(repo);
982
+ const failure = await host.codeIn(local.place).bringCodeIn({ repository: repo, spaceId: SPACE_ID, timeoutMs: 20_000 }).catch((error) => error);
983
+ expect(failure).toMatchObject({ code: 'code_transfer_failed', details: { step: 'the base commit', cause } });
984
+ // The receiving side really ran, with the command the host wrote out, and did what it was set to.
985
+ expect(local.lastCommand().slice(4)).toEqual(['/usr/bin/git', 'receive-pack', `/spaces/${SPACE_ID}/bait-repo-`]);
986
+ expect(failure.message).toContain(printed);
987
+ expect(g(['for-each-ref', 'refs/openchamber/'])).toBe('');
988
+ expect(changedSince(before, repo)).toEqual([]);
989
+ });
990
+
991
+ it('ends a space that never answers at the host timeout, and leaves no host process behind', async () => {
992
+ const host = createTestHost();
993
+ const { repo, g } = makeBait(host);
994
+ const local = createLocalPlace(host, 'hang');
995
+ const before = hostState(repo);
996
+ const failure = await host.codeIn(local.place).bringCodeIn({ repository: repo, spaceId: SPACE_ID, timeoutMs: 3000 }).catch((error) => error);
997
+ expect(failure).toMatchObject({ code: 'code_transfer_failed', details: { cause: 'command_timeout' } });
998
+ const pid = local.hangPid();
999
+ expect(Number.isInteger(pid) && pid > 1).toBe(true);
1000
+ const alive = () => {
1001
+ try {
1002
+ process.kill(pid, 0);
1003
+ return true;
1004
+ } catch {
1005
+ return false;
1006
+ }
1007
+ };
1008
+ for (let attempt = 0; attempt < 50 && alive(); attempt += 1) {
1009
+ await new Promise((resolve) => { setTimeout(resolve, 100); });
1010
+ }
1011
+ const survived = alive();
1012
+ if (survived) process.kill(pid, 'SIGKILL');
1013
+ expect(survived).toBe(false);
1014
+ expect(g(['for-each-ref', 'refs/openchamber/'])).toBe('');
1015
+ expect(changedSince(before, repo)).toEqual([]);
1016
+ });
1017
+
1018
+ it('refuses a project path that is already taken inside, says what to do, keeps what is there, and changes nothing on the host', async () => {
1019
+ const host = createTestHost();
1020
+ const { repo, g } = makeBait(host);
1021
+ const local = createLocalPlace(host);
1022
+ const taken = local.local(`/spaces/${SPACE_ID}/bait-repo-`);
1023
+ fs.mkdirSync(taken);
1024
+ fs.writeFileSync(path.join(taken, 'keep.txt'), 'keep\n');
1025
+ const before = hostState(repo);
1026
+ const failure = await host.codeIn(local.place).bringCodeIn({ repository: repo, spaceId: SPACE_ID, timeoutMs: 20_000 }).catch((error) => error);
1027
+ expect(failure).toMatchObject({ code: 'space_path_in_use' });
1028
+ expect(failure.message).toMatch(/remove this space and create a new one/);
1029
+ expect(fs.readFileSync(path.join(taken, 'keep.txt'), 'utf8')).toBe('keep\n');
1030
+ expect(g(['for-each-ref', 'refs/openchamber/'])).toBe('');
1031
+ expect(changedSince(before, repo)).toEqual([]);
1032
+ });
1033
+
1034
+ it('reports a failure inside the history with its cause, removes the side repository, and changes nothing on the host', async () => {
1035
+ const host = createTestHost();
1036
+ const { repo } = makeBait(host);
1037
+ const local = createLocalPlace(host);
1038
+ const codeIn = host.codeIn(local.place);
1039
+ const result = await codeIn.bringCodeIn({ repository: repo, spaceId: SPACE_ID, timeoutMs: 60_000 });
1040
+ // Something inside holds the lock that the unshallow needs.
1041
+ fs.writeFileSync(path.join(local.local(result.spacePath), '.git', 'shallow.lock'), '');
1042
+ const before = hostState(repo);
1043
+ const failure = await codeIn.sendHistory({ repository: repo, spaceId: SPACE_ID, spacePath: result.spacePath, base: result.base, timeoutMs: 20_000 }).catch((error) => error);
1044
+ expect(failure).toMatchObject({ code: 'history_transfer_failed', details: { step: 'take the history into the repository', cause: 'inside_command_failed' } });
1045
+ expect(fs.existsSync(local.local(`/spaces/${SPACE_ID}/.openchamber-history.git`))).toBe(false);
1046
+ expect(changedSince(before, repo)).toEqual([]);
1047
+ });
1048
+ });