@vantran-se/openchamber-web 2.0.4 → 2.0.5

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 (243) hide show
  1. package/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-DCAQTLMM.js} +1 -1
  2. package/dist/assets/DiffView-uzNpHBlV.js +4 -0
  3. package/dist/assets/FilesView-Bkq0sjNx.js +17 -0
  4. package/dist/assets/{GitView-DwQwqDvu.js → GitView-BmibZC7n.js} +4 -4
  5. package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
  6. package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
  7. package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-BNT2yUVu.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-D3Azksju.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-BGY6tuiK.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-DJmhQ37f.js +12 -0
  11. package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-D_41H2qC.js} +1 -1
  12. package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-C0RSqa9W.js} +1 -1
  13. package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
  14. package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-Bqc-xuDe.js} +4 -4
  15. package/dist/assets/PluginPane-DQ0k41Q-.js +3 -0
  16. package/dist/assets/SettingsView-BAiJU5hJ.js +30 -0
  17. package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DuC4ecLw.js} +1 -1
  18. package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
  20. package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
  21. package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
  22. package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-BJicp0RC.js} +2 -2
  23. package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-CiuyzfSW.js} +1 -1
  24. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  25. package/dist/assets/commitAndPush-DTKb6IwT.js +1 -0
  26. package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kM_zJNMt.js} +2 -2
  27. package/dist/assets/de-BMnfXm-G.js +10 -0
  28. package/dist/assets/es-DKEvCCL6.js +16 -0
  29. package/dist/assets/fr-D9fgE2xE.js +14 -0
  30. package/dist/assets/index-DUke1nme.css +1 -0
  31. package/dist/assets/{index-OKDYjcMF.js → index-DWB8bR_g.js} +1 -1
  32. package/dist/assets/index-Di6zUe5h.js +41 -0
  33. package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-DOoIsGqs.js} +1 -1
  34. package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-bt5VHvcd.js} +1 -1
  35. package/dist/assets/ja-BEXad-KW.js +16 -0
  36. package/dist/assets/ko-DGrQ0lEe.js +16 -0
  37. package/dist/assets/{main-CGEvLMd2.js → main-BpLQbZC2.js} +2 -2
  38. package/dist/assets/main-CI9DtHJv.js +896 -0
  39. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  40. package/dist/assets/markdownCore-DK2KK5RW.js +7 -0
  41. package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-CIWaR8wG.js} +2 -2
  42. package/dist/assets/{mobile-C5KRTNOB.js → mobile-CnWKEYIP.js} +2 -2
  43. package/dist/assets/patchFileDiff-Cqn0C0az.js +13 -0
  44. package/dist/assets/pl-BlhkmR5b.js +16 -0
  45. package/dist/assets/pt-BR-Bs96Ew9I.js +16 -0
  46. package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-D9t0-9nV.js} +2 -2
  47. package/dist/assets/renderMobileApp-CW7fVs_s.js +25 -0
  48. package/dist/assets/runtimeEndpointReset-D0PcCb3K.js +27 -0
  49. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  50. package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-l-MbaNS4.js} +1 -1
  51. package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-C-kGcOpD.js} +1 -1
  52. package/dist/assets/tr-CehUK1M9.js +7 -0
  53. package/dist/assets/uk-Bno5WGNz.js +16 -0
  54. package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-DDjuwVqi.js} +160 -156
  55. package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Clg8EO3A.js} +2 -2
  56. package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
  57. package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-oKF0MAwd.js} +2 -2
  58. package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
  59. package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
  60. package/dist/assets/{web-components-BEo4-ISu.js → web-components-Bc1WPd4i.js} +1 -1
  61. package/dist/assets/zh-CN-DnAweIWo.js +16 -0
  62. package/dist/assets/zh-TW-BbBBK25p.js +16 -0
  63. package/dist/index.html +5 -5
  64. package/dist/mini-chat.html +5 -5
  65. package/dist/mobile.html +5 -5
  66. package/package.json +3 -3
  67. package/server/index.js +82 -0
  68. package/server/lib/agent-tool/DOCUMENTATION.md +132 -47
  69. package/server/lib/agent-tool/runtime.js +34 -15
  70. package/server/lib/agent-tool/runtime.test.js +15 -0
  71. package/server/lib/browser-control/DOCUMENTATION.md +14 -2
  72. package/server/lib/event-stream/global-hub.js +23 -1
  73. package/server/lib/event-stream/global-hub.test.js +35 -0
  74. package/server/lib/event-stream/global-ws-bridge.js +3 -1
  75. package/server/lib/event-stream/upstream-reader.js +3 -2
  76. package/server/lib/git/DOCUMENTATION.md +1 -1
  77. package/server/lib/git/service.js +22 -0
  78. package/server/lib/git/service.test.js +8 -0
  79. package/server/lib/guests/DOCUMENTATION.md +4 -3
  80. package/server/lib/guests/catalog.js +19 -1
  81. package/server/lib/guests/catalog.test.js +22 -0
  82. package/server/lib/guests/html-styles.js +5 -3
  83. package/server/lib/guests/html-styles.test.js +3 -1
  84. package/server/lib/guests/routes.js +11 -3
  85. package/server/lib/guests/service.js +5 -0
  86. package/server/lib/guests/surface.js +55 -9
  87. package/server/lib/guests/surface.test.js +56 -6
  88. package/server/lib/notifications/DOCUMENTATION.md +10 -0
  89. package/server/lib/notifications/emit-route.js +118 -0
  90. package/server/lib/notifications/emit-route.test.js +112 -0
  91. package/server/lib/openchamber-control/actions.js +16 -2
  92. package/server/lib/openchamber-control/service.js +23 -0
  93. package/server/lib/openchamber-control/service.test.js +31 -0
  94. package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
  95. package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
  96. package/server/lib/openchamber-sessions/routes.js +11 -0
  97. package/server/lib/openchamber-sessions/routes.test.js +18 -0
  98. package/server/lib/opencode/bootstrap-runtime.js +14 -1
  99. package/server/lib/opencode/compatibility.js +6 -3
  100. package/server/lib/opencode/compatibility.test.js +8 -0
  101. package/server/lib/opencode/config-entity-v2.test.js +27 -0
  102. package/server/lib/opencode/config-paths.test.js +12 -0
  103. package/server/lib/opencode/config-v2.d.ts +2 -0
  104. package/server/lib/opencode/config-v2.js +42 -0
  105. package/server/lib/opencode/config-v2.test.js +17 -0
  106. package/server/lib/opencode/core-routes.js +20 -4
  107. package/server/lib/opencode/core-routes.test.js +41 -0
  108. package/server/lib/opencode/env-runtime.js +1 -8
  109. package/server/lib/opencode/feature-routes-runtime.js +2 -1
  110. package/server/lib/opencode/lifecycle.js +30 -2
  111. package/server/lib/opencode/lifecycle.test.js +27 -0
  112. package/server/lib/opencode/managed-config-file.js +18 -6
  113. package/server/lib/opencode/managed-config-file.test.js +23 -7
  114. package/server/lib/opencode/mcp.js +7 -2
  115. package/server/lib/opencode/npm-registry-config.d.ts +1 -0
  116. package/server/lib/opencode/npm-registry-config.js +123 -0
  117. package/server/lib/opencode/npm-registry-config.test.js +167 -0
  118. package/server/lib/opencode/npm-registry.js +5 -6
  119. package/server/lib/opencode/npm-registry.test.js +30 -0
  120. package/server/lib/opencode/openchamber-routes.js +29 -11
  121. package/server/lib/opencode/openchamber-routes.test.js +18 -0
  122. package/server/lib/opencode/project-directory-runtime.js +8 -0
  123. package/server/lib/opencode/project-directory-runtime.test.js +13 -0
  124. package/server/lib/opencode/provider-source-route.test.js +59 -0
  125. package/server/lib/opencode/providers.js +3 -0
  126. package/server/lib/opencode/providers.test.js +62 -0
  127. package/server/lib/opencode/proxy.js +38 -2
  128. package/server/lib/opencode/routes-upgrade.test.js +1 -1
  129. package/server/lib/opencode/routes.js +36 -1
  130. package/server/lib/opencode/server-utils-runtime.js +5 -0
  131. package/server/lib/opencode/settings-helpers.js +10 -0
  132. package/server/lib/opencode/settings-helpers.test.js +2 -2
  133. package/server/lib/opencode/settings-registry.json +17 -0
  134. package/server/lib/opencode/settings-runtime.js +1 -0
  135. package/server/lib/opencode/shared.js +49 -4
  136. package/server/lib/opencode/shared.test.js +78 -1
  137. package/server/lib/opencode/shutdown-runtime.js +3 -0
  138. package/server/lib/opencode/skill-routes.js +28 -12
  139. package/server/lib/opencode/skill-routes.test.js +121 -1
  140. package/server/lib/opencode/upgrade-capability.js +0 -14
  141. package/server/lib/opencode/v1-migration-topup.js +21 -15
  142. package/server/lib/opencode/v1-migration-topup.test.js +14 -6
  143. package/server/lib/opencode/v2-install.d.ts +1 -0
  144. package/server/lib/opencode/v2-install.js +8 -7
  145. package/server/lib/opencode/v2-install.test.js +13 -4
  146. package/server/lib/opencode/watcher.js +3 -1
  147. package/server/lib/opencode/websearch-config.js +13 -1
  148. package/server/lib/quota/providers/kimi.js +1 -1
  149. package/server/lib/realtime-proxy.js +5 -1
  150. package/server/lib/realtime-proxy.test.js +29 -0
  151. package/server/lib/relay/tunnel-host.js +4 -1
  152. package/server/lib/relay/tunnel-host.test.js +11 -0
  153. package/server/lib/session-assist/DOCUMENTATION.md +17 -5
  154. package/server/lib/session-assist/runtime.js +19 -2
  155. package/server/lib/session-assist/runtime.test.js +8 -0
  156. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  157. package/server/lib/small-model/DOCUMENTATION.md +11 -10
  158. package/server/lib/small-model/index.js +27 -32
  159. package/server/lib/small-model/index.test.js +28 -16
  160. package/server/lib/spaces/DOCUMENTATION.md +404 -32
  161. package/server/lib/spaces/code-in-bait.js +145 -6
  162. package/server/lib/spaces/code-in.js +10 -150
  163. package/server/lib/spaces/code-in.test.js +7 -83
  164. package/server/lib/spaces/code-out.js +1819 -0
  165. package/server/lib/spaces/code-out.test.js +3945 -0
  166. package/server/lib/spaces/code-transfer.js +173 -0
  167. package/server/lib/spaces/command-stream.test.js +181 -0
  168. package/server/lib/spaces/dispatcher.js +670 -0
  169. package/server/lib/spaces/dispatcher.test.js +582 -0
  170. package/server/lib/spaces/gatekeeper-channel.js +61 -7
  171. package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
  172. package/server/lib/spaces/gatekeeper-program.cjs +68 -14
  173. package/server/lib/spaces/gatekeeper-program.test.js +118 -3
  174. package/server/lib/spaces/hardening.js +20 -4
  175. package/server/lib/spaces/hardening.test.js +18 -3
  176. package/server/lib/spaces/host-git.js +7 -4
  177. package/server/lib/spaces/host-git.test.js +6 -2
  178. package/server/lib/spaces/host.js +310 -0
  179. package/server/lib/spaces/host.test.js +273 -0
  180. package/server/lib/spaces/journey.js +552 -0
  181. package/server/lib/spaces/journey.test.js +589 -0
  182. package/server/lib/spaces/layout.js +42 -8
  183. package/server/lib/spaces/manager.js +4 -3
  184. package/server/lib/spaces/manager.test.js +6 -0
  185. package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
  186. package/server/lib/spaces/places/contract-suite.js +36 -0
  187. package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
  188. package/server/lib/spaces/places/docker-live-support.js +27 -4
  189. package/server/lib/spaces/places/docker.js +33 -5
  190. package/server/lib/spaces/places/docker.test.js +42 -3
  191. package/server/lib/spaces/places/escape-suite.js +118 -14
  192. package/server/lib/spaces/places/fake-docker.js +3 -1
  193. package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
  194. package/server/lib/spaces/places/memory-place.js +24 -1
  195. package/server/lib/spaces/places/registry.js +1 -1
  196. package/server/lib/spaces/places/server.docker.live.test.js +69 -33
  197. package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
  198. package/server/lib/spaces/routes.js +216 -0
  199. package/server/lib/spaces/routes.test.js +213 -0
  200. package/server/lib/spaces/run-command.js +261 -8
  201. package/server/lib/spaces/run-command.test.js +193 -3
  202. package/server/lib/spaces/space-events.js +153 -0
  203. package/server/lib/spaces/space-events.test.js +160 -0
  204. package/server/lib/spaces/space-opencode.js +52 -0
  205. package/server/lib/spaces/space-opencode.test.js +41 -0
  206. package/server/lib/spaces/space-records.js +150 -0
  207. package/server/lib/spaces/space-records.test.js +113 -0
  208. package/server/lib/spaces/space-sessions.js +210 -0
  209. package/server/lib/spaces/space-sessions.test.js +161 -0
  210. package/server/lib/spaces/websocket.js +329 -0
  211. package/server/lib/spaces/websocket.test.js +345 -0
  212. package/server/lib/ui-auth/ui-auth.js +8 -2
  213. package/server/lib/ui-auth/ui-auth.test.js +29 -0
  214. package/server/opencode-proxy.test.js +113 -0
  215. package/dist/assets/DiffView-D2p4Ar0H.js +0 -4
  216. package/dist/assets/FilesView-BeTtbsip.js +0 -17
  217. package/dist/assets/GuestSurfacePane-DIJvkkM-.js +0 -1
  218. package/dist/assets/MarkdownImageGallery-CY0dWTaE.js +0 -1
  219. package/dist/assets/MarkdownRendererImpl-ILdXzLrt.js +0 -12
  220. package/dist/assets/PluginPane-B39JEUL8.js +0 -3
  221. package/dist/assets/SettingsView-13B2_G-A.js +0 -29
  222. package/dist/assets/commitAndPush-CcI4ol7c.js +0 -1
  223. package/dist/assets/de-BtYnUgII.js +0 -10
  224. package/dist/assets/es-crP5qPye.js +0 -16
  225. package/dist/assets/fr-CLSRuALk.js +0 -14
  226. package/dist/assets/index-CLcM8HKx.css +0 -1
  227. package/dist/assets/index-CvLgnuNI.js +0 -41
  228. package/dist/assets/ja-SZ1-ucLV.js +0 -16
  229. package/dist/assets/ko-HthbT_Ve.js +0 -16
  230. package/dist/assets/main-BJklC7Cl.js +0 -896
  231. package/dist/assets/markdownCore-BzQuETm0.js +0 -5
  232. package/dist/assets/patchFileDiff-DGw2KryM.js +0 -13
  233. package/dist/assets/pl-C8eYfjL1.js +0 -16
  234. package/dist/assets/pt-BR-cYOrZdxS.js +0 -16
  235. package/dist/assets/renderMobileApp-CMxjwisj.js +0 -25
  236. package/dist/assets/runtimeEndpointReset-C45jrFq4.js +0 -27
  237. package/dist/assets/scheduledTasksApi-CkK1F9s2.js +0 -1
  238. package/dist/assets/tr-B4IQ5t-T.js +0 -7
  239. package/dist/assets/uk-DCoXG6ve.js +0 -16
  240. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
  241. package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
  242. package/dist/assets/zh-CN-BXBoxsFk.js +0 -16
  243. package/dist/assets/zh-TW-C6oelpnL.js +0 -16
@@ -0,0 +1,1819 @@
1
+ // Code out: the result of a space travels back to the user's repository as git objects over the
2
+ // place's exec channel, and is applied there as a branch or as uncommitted changes. The host drives
3
+ // every step. The command sequences follow docs/isolated-spaces/stage-0/e4-git-over-exec.md.
4
+ //
5
+ // Everything that comes out of a space is untrusted data. The space can send hostile objects, send
6
+ // without end, hang, lie about its history, or print a lot. Its objects land in a throwaway
7
+ // quarantine repository first and are checked there; the user's repository gets them only after
8
+ // that, and the patch that is applied is built on the host from two trees it holds.
9
+
10
+ import crypto from 'node:crypto';
11
+ import { constants, createReadStream } from 'node:fs';
12
+ import fs from 'node:fs/promises';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import readline from 'node:readline';
16
+
17
+ import {
18
+ EXT_UNCARRIABLE, INNER_MARGIN_SECONDS, SNAPSHOT_IDENTITY, buildExtUrl, createTransferSession, innerSeconds, line,
19
+ objectIdPattern, requireInnerMargin, requireTimeout, spaceRefPrefix, startRef, zeroObjectId,
20
+ } from './code-transfer.js';
21
+ import { SpaceError } from './errors.js';
22
+ import { tail } from './exec-http.js';
23
+ import { requireSpaceId } from './labels.js';
24
+ import { IMAGE_GIT, IMAGE_ONLY_PATH, IMAGE_SH, IMAGE_TIMEOUT, requireSpaceProjectPath } from './layout.js';
25
+
26
+ // The host's deadline for the fetch out of the space, the same as for a push of code in.
27
+ const FETCH_TIMEOUT_MS = 10 * 60_000;
28
+ // The snapshot inside is `add --all` over the agent's working tree, which a large tree makes slow.
29
+ const SNAPSHOT_TIMEOUT_MS = 5 * 60_000;
30
+ // Building the patch and applying it read and write up to `maxChangedBytes`.
31
+ const APPLY_TIMEOUT_MS = 10 * 60_000;
32
+ // Compressed bytes the space may send, measured on the quarantine folder while it grows. Twice the
33
+ // changed-bytes cap below: a pack of the new objects is not larger than what they inflate to, plus
34
+ // commits and trees. Measured locally, the quarantine overshoots by what arrives in one poll. It
35
+ // counts bytes on the wire, so it says little about what they inflate to; that is what the caps
36
+ // after the fetch are for.
37
+ const MAX_TRANSFER_BYTES = 256 * 1024 * 1024;
38
+ // Bytes one result may be worth, on two counts: everything new its history brings, which is what the
39
+ // user's repository keeps, and what a patch of it would read and write, which is what an apply costs.
40
+ // Both sides of every changed path count, deletions included. Measured on macOS with git 2.50.1, the
41
+ // worst case this cap allows: a 64 MiB text file rewritten line by line, 128 MiB charged, made a
42
+ // 137 MB patch on disk, `diff-tree -p --binary` peak at 0.41 GB and `apply` at 0.54 GB. Twice this
43
+ // cap costs twice that: 256 MiB charged took 1.6 GB and 2.2 GB, and at 1 GiB `git apply` refuses the
44
+ // patch outright, a limit of git's own.
45
+ const MAX_CHANGED_BYTES = 128 * 1024 * 1024;
46
+ // What one object that is not a file may hold. A commit message or a tree entry list of real work is
47
+ // far below this; four megabytes is a tree of about a hundred thousand entries. Such an object is
48
+ // cheap on the wire and expensive on the host, which is why it has a cap of its own: measured by a
49
+ // reviewer, a 250 MiB commit message travelled as 256 KB and made `diff-tree` peak at 505 MB.
50
+ const MAX_OBJECT_BYTES = 4 * 1024 * 1024;
51
+ // Changed paths in the result, and new objects in its history. A hundred thousand is more than a
52
+ // whole large repository's files, and bounds every list the host reads.
53
+ const MAX_CHANGED_ENTRIES = 100_000;
54
+ // How deep, in all, the changed paths and their folders may lie for an apply as uncommitted changes,
55
+ // per entry of the cap above: 3.2 million steps from a folder into the next by default. An ordinary
56
+ // large change is far below it: twenty thousand files three folders deep, each in folders of its own,
57
+ // is 180,000, and a hundred thousand files twelve deep in twenty thousand folders about 1.4 million.
58
+ // What it allows at most was measured on macOS, see the measurement notes in DOCUMENTATION.md.
59
+ const FOLDER_STEPS_PER_ENTRY = 32;
60
+ // A quiet fetch prints nothing. What it does print comes from the space.
61
+ const FETCH_MAX_OUTPUT_BYTES = 1024 * 1024;
62
+ const LIST_MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
63
+ // What a `git apply` keeps of what it prints: the end of it, for the message. The exit code is the
64
+ // answer, so a check that prints a line for each of a hundred thousand paths must not fail for that.
65
+ const APPLY_OUTPUT_TAIL_BYTES = 64 * 1024;
66
+ // How long one call may spend reading the working tree back, to tell whether the user kept or threw
67
+ // away the last apply and where an interrupted one stopped. Every file of the last apply is read, by
68
+ // its bytes, and through the user's clean filters where the bytes are neither side's: measured by a
69
+ // reviewer on the seventh round, which read every file through the filters, a hundred thousand files
70
+ // took 6.9 s, and 276 s with a clean filter that starts a program per file. Past this the call answers
71
+ // `changes_undecided` instead of failing on every later call.
72
+ const READ_BACK_TIMEOUT_MS = 5 * 60_000;
73
+ const SIZE_POLL_MS = 100;
74
+
75
+ // The ref the snapshot inside writes, and the one the quarantine fetches it into.
76
+ const INSIDE_RESULT = 'refs/openchamber/result';
77
+ const QUARANTINE_RESULT = 'refs/openchamber/result';
78
+ const resultRef = (spaceId) => `${spaceRefPrefix(spaceId)}result`;
79
+ // What the last apply as uncommitted changes wrote into the working tree. A second apply starts from
80
+ // it, so it brings only what is new since then. A service ref of ours, which is also what keeps the
81
+ // commit alive through a garbage collection; DESIGN.md allows these under `refs/openchamber/`.
82
+ const appliedRef = (spaceId) => `${spaceRefPrefix(spaceId)}applied`;
83
+ // Written once the work of a space and the user's project have gone apart, at the result that was
84
+ // refused. From then on this space is applied as a branch, and nothing in this stage reopens it.
85
+ const closedRef = (spaceId) => `${spaceRefPrefix(spaceId)}changes-closed`;
86
+ // Written just before `git apply` writes into the working tree, and replaced by `applied` once it is
87
+ // done. Found on a later call, it means the host went away in between: that call looks at the working
88
+ // tree and finishes the record, forgets the attempt, or says that part of it is there.
89
+ const applyingRef = (spaceId) => `${spaceRefPrefix(spaceId)}applying`;
90
+ // What the patch of the last apply was built from, beside `applied`, and of the apply under way, beside
91
+ // `applying`: the start, or the apply before it. Each moves with its partner in one transaction. When
92
+ // the user threw the last apply away, every path it changed is back as this ref has it, and the next
93
+ // apply brings the whole work again from here. A repository without it, from before, never does.
94
+ const appliedFromRef = (spaceId) => `${spaceRefPrefix(spaceId)}applied-from`;
95
+ const applyingFromRef = (spaceId) => `${spaceRefPrefix(spaceId)}applying-from`;
96
+ // The commit the user's HEAD named when the last apply wrote, beside `applied`, and when the apply under
97
+ // way began, beside `applying`, moving with them in the same transactions; absent while HEAD had no
98
+ // commit yet. A path of the last apply that a commit of the user's changed since, from this commit to
99
+ // the HEAD of the working tree, is the user's own change and never thrown away; see `whatBecameOf`.
100
+ const appliedHeadRef = (spaceId) => `${spaceRefPrefix(spaceId)}applied-head`;
101
+ const applyingHeadRef = (spaceId) => `${spaceRefPrefix(spaceId)}applying-head`;
102
+ // What the user reads in every refusal that closes the route, and in every attempt after it.
103
+ const BRANCH_FROM_NOW_ON = 'From now on this space is applied as a branch, which holds its whole work, the rounds you already applied included.';
104
+
105
+ const UNCOMMITTED_MESSAGE = 'openchamber: uncommitted changes from the space\n\nWhat was uncommitted in the space when its work was brought out. Some of it may be the work you had uncommitted when the space was made.';
106
+
107
+ // Paths the host reports back from one call. They are for the caller to show, and the count says how
108
+ // many there are when the list stops here.
109
+ const MAX_REPORTED_PATHS = 100;
110
+
111
+ // How much of the unmerged listing the script sends back. A longer one is cut there and the count
112
+ // says what was read, so a space cannot fail the whole code out by printing without end.
113
+ const MAX_REPORT_BYTES = 256 * 1024;
114
+
115
+ // The fixed script inside, run under the image's `timeout`. Every value is a positional argument:
116
+ // $1 the project path, $2 the message, $3 the name, $4 the email. A copy of the index takes the
117
+ // working tree, so the agent's own index and working tree stay as they are. When nothing is
118
+ // uncommitted, the result is HEAD itself.
119
+ //
120
+ // Its report goes on a channel of its own, file descriptor 3, which is this script's real stdout;
121
+ // everything the script runs writes to /dev/null instead and runs with descriptor 3 closed, so what
122
+ // git and the agent's hooks print does not land in the report, and a hook cannot write to it through
123
+ // the descriptors it inherits. That keeps the report readable; it does not keep it private. A process
124
+ // of the agent's reaches the channel all the same on Linux, through /proc: the shell keeps a copy of
125
+ // descriptor 3 while the block runs (dash on fd 10), and /proc/<pid>/fd opens it for the same user.
126
+ // Only the two commands at the end write the report. The report is the id of the commit the script
127
+ // made, then the unmerged entries of the space's index, read before `add --all` stages their
128
+ // conflicted content. It is data from the space and is treated as such: the host takes the id only to
129
+ // require that exact object from the fetch, which checks it like any result, and the paths only to
130
+ // show. A report the agent wrote itself can name another commit of its own, which the agent could as
131
+ // well have made its result, and paths that do not belong to it.
132
+ const SNAPSHOT_SCRIPT = [
133
+ IMAGE_ONLY_PATH,
134
+ 'exec 3>&1 >/dev/null;',
135
+ '{',
136
+ 'cd "$1" || exit 1;',
137
+ 'report=$(git rev-parse --git-path openchamber-unmerged) || exit 1;',
138
+ `git ls-files --unmerged -z | head -c ${MAX_REPORT_BYTES} > "$report";`,
139
+ 'head=$(git rev-parse --verify --quiet "HEAD^{commit}") || { echo "The repository in the space has no commit at HEAD." >&2; exit 1; };',
140
+ 'index=$(git rev-parse --git-path index) && copy=$(git rev-parse --git-path openchamber-result-index) || exit 1;',
141
+ 'rm -f "$copy";',
142
+ 'if [ -e "$index" ]; then cp "$index" "$copy" || exit 1; fi;',
143
+ 'GIT_INDEX_FILE="$copy" git -c core.splitIndex=false add --all && tree=$(GIT_INDEX_FILE="$copy" git -c core.splitIndex=false write-tree);',
144
+ 'status=$?; rm -f "$copy"; [ "$status" -eq 0 ] || exit 1;',
145
+ 'if [ "$tree" = "$(git rev-parse "$head^{tree}")" ]; then result=$head;',
146
+ 'else result=$(GIT_AUTHOR_NAME="$3" GIT_AUTHOR_EMAIL="$4" GIT_COMMITTER_NAME="$3" GIT_COMMITTER_EMAIL="$4" git commit-tree --no-gpg-sign "$tree" -p "$head" -m "$2") || exit 1; fi;',
147
+ `git update-ref ${INSIDE_RESULT} "$result" || exit 1;`,
148
+ '} 3>&-;',
149
+ 'printf "%s\n" "$result" >&3;',
150
+ 'cat "$report" >&3;',
151
+ 'rm -f "$report"',
152
+ ].join(' ');
153
+
154
+ // Config for every fetch of code out. Objects are checked on the way in. Nothing is pruned, no
155
+ // commit graph is written into the user's `.git`, and no remote config is read, because the
156
+ // source is a URL and the refspec is explicit.
157
+ const FETCH_CONFIG = [
158
+ '-c', 'fetch.fsckObjects=true', '-c', 'transfer.fsckObjects=true',
159
+ '-c', 'fetch.prune=false', '-c', 'fetch.writeCommitGraph=false',
160
+ ];
161
+ // The quarantine keeps what arrives as a pack, written while it arrives, so its folder grows with the
162
+ // transfer and the cap sees it. Below the unpack limit, 100 objects by default, git unpacks into
163
+ // loose objects and writes each only once it is whole, a single large file at the very end. Measured
164
+ // with git 2.50.1, `fetch.fsckObjects` alone already makes git keep a pack; this does not rely on it.
165
+ const QUARANTINE_CONFIG = ['-c', 'fetch.unpackLimit=1', '-c', 'transfer.unpackLimit=1'];
166
+ // `--refmap=` maps nothing beyond the one refspec. No FETCH_HEAD, no tags, no submodules, no gc.
167
+ const FETCH_FLAGS = ['--quiet', '--no-tags', '--no-recurse-submodules', '--no-write-fetch-head', '--no-auto-gc', '--refmap='];
168
+
169
+ // The patch, from git's plumbing, which ignores the user's `diff.noprefix`, `diff.relative`,
170
+ // colours and prefixes. The rest is said explicitly anyway.
171
+ const PATCH_ARGS = ['diff-tree', '-r', '-p', '--binary', '--full-index', '--no-renames', '--no-ext-diff', '--no-textconv', '--no-color', '--src-prefix=a/', '--dst-prefix=b/'];
172
+ // `apply.whitespace=fix` changed content silently and `apply.whitespace=error` refused, measured with
173
+ // git 2.50.1; `apply.ignoreWhitespace=change` lets a patch land on lines that differ. Those are the
174
+ // only two `apply.*` settings in git's manual. Never `--unsafe-paths`, `--3way` or `--index`.
175
+ const APPLY_ARGS = ['-c', 'apply.whitespace=nowarn', '-c', 'apply.ignoreWhitespace=no', 'apply', '--binary', '--whitespace=nowarn'];
176
+
177
+ const LIMIT_NAMES = ['maxTransferBytes', 'maxChangedBytes', 'maxChangedEntries'];
178
+ const requireLimit = (value, name) => {
179
+ if (!Number.isSafeInteger(value) || value < 1) {
180
+ throw new SpaceError('invalid_limit', `${name} is a whole number greater than zero`);
181
+ }
182
+ return value;
183
+ };
184
+
185
+ // What `bringCodeOut` rejects with as it is. Everything else, a failure of the place, of the runner
186
+ // or of a host git step, becomes code_out_failed with that code as `details.cause`.
187
+ const OUT_CODES = new Set([
188
+ 'invalid_space_id', 'invalid_space_path', 'invalid_timeout', 'invalid_inner_margin', 'invalid_limit',
189
+ 'git_version_unreadable', 'git_too_old', 'project_folder_missing', 'not_a_git_work_tree', 'space_start_missing',
190
+ 'code_out_failed', 'result_ref_missing', 'result_not_a_commit', 'result_transfer_too_large', 'result_too_large', 'result_too_many_changes',
191
+ ]);
192
+
193
+ const pause = (milliseconds) => new Promise((resolve) => { setTimeout(resolve, milliseconds); });
194
+
195
+ // Reading the working tree back: how many files are read at once, and how much of one file is held at
196
+ // a time. A file is hashed as it is read and never held whole.
197
+ const READ_AT_ONCE = 8;
198
+ const READ_PIECE_BYTES = 64 * 1024;
199
+
200
+ /** The id git gives `bytes` as a blob, in the repository's hash `algorithm`. */
201
+ const blobId = (algorithm, bytes) => crypto.createHash(algorithm).update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
202
+
203
+ // What `hashFile` answers for a file that changed size while it was read: an editor saving it at that
204
+ // moment. `lookAt` reads it again a few times before it gives up on it.
205
+ const CHANGED_WHILE_READ = Symbol('changed while read');
206
+ // How many times one file is read before a file that keeps changing is answered as undecided, and the
207
+ // pause between two reads. Within the call's time like every read.
208
+ const READS_OF_A_CHANGING_FILE = 4;
209
+ const PAUSE_BETWEEN_READS_MS = 100;
210
+
211
+ /**
212
+ * The id git would give the bytes of the file at `full` as a blob, read a piece at a time and never
213
+ * whole; null when it cannot be read as one plain file: the open or a read fails, or what was opened
214
+ * is no plain file, a link swapped in included, which is never followed; `CHANGED_WHILE_READ` when it
215
+ * changed size while it was read. The open does not wait: a FIFO swapped in after the `lstat` would otherwise hold the call in
216
+ * an open that never returns, where no deadline is checked. Past `until` it rejects with
217
+ * `command_timeout`, as a git command past its time would. `files` is `node:fs/promises`, or what a
218
+ * test hands in instead.
219
+ */
220
+ async function hashFile(files, full, algorithm, until) {
221
+ let handle;
222
+ try {
223
+ handle = await files.open(full, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
224
+ } catch {
225
+ return null;
226
+ }
227
+ try {
228
+ const stats = await handle.stat();
229
+ if (!stats.isFile()) return null;
230
+ const hash = crypto.createHash(algorithm).update(`blob ${stats.size}\0`);
231
+ const piece = Buffer.allocUnsafe(Math.max(1, Math.min(READ_PIECE_BYTES, stats.size)));
232
+ let total = 0;
233
+ for (;;) {
234
+ if (Date.now() > until) throw new SpaceError('command_timeout', 'Reading a file of the working tree back did not end in time');
235
+ const { bytesRead } = await handle.read(piece, 0, piece.length, null).catch(() => ({ bytesRead: -1 }));
236
+ if (bytesRead < 0) return null;
237
+ if (bytesRead === 0) break;
238
+ total += bytesRead;
239
+ if (total > stats.size) return CHANGED_WHILE_READ;
240
+ hash.update(piece.subarray(0, bytesRead));
241
+ }
242
+ return total === stats.size ? hash.digest('hex') : CHANGED_WHILE_READ;
243
+ } finally {
244
+ await handle.close();
245
+ }
246
+ }
247
+
248
+ // Applies as uncommitted changes, and reads of where one stands, under way in this process, by
249
+ // repository and space: the promise the next one waits for. Two applies of one space at once each
250
+ // passed the dry run against the same working tree, then the second wrote over the first and found
251
+ // its patch no longer fitting, and closed the route. One waits for the other now, and then answers
252
+ // from what the first left: nothing to apply, or only what is new. At module level, so two
253
+ // `createCodeOut` instances share it, as the history runs of code in are shared. Two processes are
254
+ // not coordinated.
255
+ const applyTurns = new Map();
256
+
257
+ /** Runs `work` once every earlier call with the same `key` in this process has ended, however it ended. */
258
+ const inTurn = async (key, work) => {
259
+ const earlier = applyTurns.get(key);
260
+ let done;
261
+ const mine = new Promise((resolve) => { done = resolve; });
262
+ applyTurns.set(key, mine);
263
+ try {
264
+ await earlier;
265
+ return await work();
266
+ } finally {
267
+ done();
268
+ if (applyTurns.get(key) === mine) applyTurns.delete(key);
269
+ }
270
+ };
271
+
272
+ /** The bytes of every file under `directory`. Entries that vanish while it counts, a pack being renamed among them, count as nothing. */
273
+ async function folderBytes(directory) {
274
+ let total = 0;
275
+ const entries = await fs.readdir(directory, { withFileTypes: true }).catch(() => []);
276
+ for (const entry of entries) {
277
+ const full = path.join(directory, entry.name);
278
+ if (entry.isDirectory()) {
279
+ total += await folderBytes(full);
280
+ } else {
281
+ total += await fs.lstat(full).then((stats) => stats.size, () => 0);
282
+ }
283
+ }
284
+ return total;
285
+ }
286
+
287
+ /**
288
+ * How this repository's file system compares names, as git itself found out when it made the
289
+ * repository: `core.ignorecase` when two names that differ only in case are one file, and
290
+ * `core.precomposeunicode` when the two Unicode spellings of an accent are one name, as on macOS.
291
+ * Both are read from the repository, not guessed from the operating system: a case-sensitive macOS
292
+ * volume and a case-blind disk on Linux each get what they are.
293
+ *
294
+ * The fold is wider than `toLowerCase` on purpose. APFS folds case fully, so `strasse` and `straße`,
295
+ * `σ` and `ς`, `fi` and `fi`, `s` and `ſ`, `β` and `ϐ` are one name there, checked on this Mac's disk
296
+ * by a test; lowering alone keeps each pair apart, and a file was lost. Lower, upper, lower again
297
+ * joins them, `ẞ` with `ss` included, which upper-then-lower alone misses. APFS also ignores the
298
+ * Unicode form wherever it ignores case, so a case-blind fold composes too. NTFS compares names
299
+ * through a table that upcases one UTF-16 unit at a time, which is narrower: `ß` and `ss` are two
300
+ * names there. Folding wider than the disk can only make more names meet. In `nameNotAllowedHere`
301
+ * that refuses more and never lets a lost file through. In `firstRedirectedFolder` it lets a real
302
+ * path pass as the same folder when it differs from git's spelling by more than the disk's own fold.
303
+ * By reasoning, not by a test: a real path names a different folder only through a link, which
304
+ * `lstat` reports before any name is compared, or through a Windows volume mount point, whose real
305
+ * path names another volume and so is not inside the work tree under any fold.
306
+ */
307
+ const nameFolder = ({ ignoreCase, precompose }) => (name) => {
308
+ if (!ignoreCase) return precompose ? name.normalize('NFC') : name;
309
+ return name.normalize('NFC').toLowerCase().toUpperCase().toLowerCase().normalize('NFC');
310
+ };
311
+
312
+ /**
313
+ * The first folder on the way to one of `paths` inside the work tree `top` that is not a plain folder
314
+ * of that work tree, or null. `git apply` must not be trusted with this: measured on Windows 11 with
315
+ * Git for Windows 2.54, it wrote through a directory junction, where git on POSIX refuses with "beyond
316
+ * a symbolic link". A folder is refused when `lstat` says it is a link, which Node reports for a
317
+ * symbolic link on every system and for a junction on Windows, because libuv turns both reparse tags
318
+ * into a link; and when its real path is not the same folder inside the real work tree, compared the
319
+ * way this file system compares names (`ignoreCase`, `precompose`), which also catches any other kind
320
+ * of redirection that is not reported as a link. Folders that do not exist yet are fine: apply makes
321
+ * them. A link the patch itself deletes, `deletedLinks`, is not in the way either: `git apply` removes
322
+ * it before it makes the folder, and its dry run first requires the link on disk to be the one the
323
+ * patch deletes. Only a link the patch deletes as a link is passed over, never a folder or a file.
324
+ *
325
+ * Each folder is looked at once with `lstat`. The real path is asked once per path, of its deepest
326
+ * folder that exists, because that one answer covers every folder above it; only when it does not
327
+ * match is each folder on the way asked, to name the first that leads elsewhere. How many folders
328
+ * that is, is bounded before this runs: `measureChange` refuses a change through more folders than
329
+ * the entries cap. `lstat` and `realpath` are for the tests, which cannot build every kind of
330
+ * redirection on every system.
331
+ */
332
+ export async function firstRedirectedFolder(top, paths, { ignoreCase = false, precompose = false, deletedLinks = [], lstat = fs.lstat, realpath = fs.realpath } = {}) {
333
+ const root = await realpath(top);
334
+ const fold = nameFolder({ ignoreCase, precompose });
335
+ const kinds = new Map();
336
+ const reals = new Map();
337
+ const goingLinks = new Set(deletedLinks);
338
+ const leadsElsewhere = async (relative) => {
339
+ if (!reals.has(relative)) {
340
+ const real = path.relative(root, await realpath(path.join(top, ...relative.split('/')))).split(path.sep).join('/');
341
+ reals.set(relative, fold(real) !== fold(relative));
342
+ }
343
+ return reals.get(relative);
344
+ };
345
+ for (const file of paths) {
346
+ const folders = file.split('/').slice(0, -1);
347
+ let relative = '';
348
+ let deepest = '';
349
+ for (const folder of folders) {
350
+ relative = relative === '' ? folder : `${relative}/${folder}`;
351
+ if (!kinds.has(relative)) {
352
+ let kind = 'folder';
353
+ try {
354
+ const stats = await lstat(path.join(top, ...relative.split('/')));
355
+ // A link the patch deletes goes before the folder comes, so there is nothing below it yet.
356
+ if (stats.isSymbolicLink()) kind = goingLinks.has(relative) ? 'absent' : 'link';
357
+ else if (!stats.isDirectory()) kind = 'other';
358
+ } catch (error) {
359
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
360
+ kind = 'absent';
361
+ }
362
+ kinds.set(relative, kind);
363
+ }
364
+ const kind = kinds.get(relative);
365
+ if (kind === 'link') return relative;
366
+ if (kind !== 'folder') break;
367
+ deepest = relative;
368
+ }
369
+ if (deepest !== '' && await leadsElsewhere(deepest)) {
370
+ let above = '';
371
+ for (const folder of deepest.split('/')) {
372
+ above = above === '' ? folder : `${above}/${folder}`;
373
+ if (await leadsElsewhere(above)) return above;
374
+ }
375
+ }
376
+ }
377
+ return null;
378
+ }
379
+
380
+ // What a file name must not be on this computer, per platform, on purpose: a space runs Linux, where
381
+ // almost any name is fine, and the user's computer may not hold it. Windows, from Microsoft's "Naming
382
+ // Files, Paths, and Namespaces" (learn.microsoft.com, windows/win32/fileio/naming-a-file) and from
383
+ // git's own `is_valid_win32_path` in compat/mingw.c, which Git for Windows applies with
384
+ // `core.protectNTFS`; a name is refused when either of the two refuses it:
385
+ // - the reserved characters < > : " / \ | ? * and the characters 1 to 31 (both sources; `/` never
386
+ // reaches a name, it is git's separator, and NUL cannot be in a git tree at all);
387
+ // - a trailing space or period (Microsoft: "Do not end a file or directory name with a space or a
388
+ // period"; git: "cannot end in ` ` or `.`");
389
+ // - the device names CON, PRN, AUX, NUL, COM1 to COM9, LPT1 to LPT9, in any case, alone or followed
390
+ // by spaces and then an extension or a colon (both sources), COM and LPT with the superscript
391
+ // digits ¹ ² ³ (Microsoft), LPT0, CONIN$ and CONOUT$ (git);
392
+ // - a whole path longer than 259 characters, unless the repository has `core.longpaths` (Microsoft:
393
+ // MAX_PATH is 260 including the terminating null; Git for Windows lifts it with that setting);
394
+ // - a whole path of a folder longer than 247 characters, unless the repository has `core.longpaths`
395
+ // (Microsoft, CreateDirectory: "the default string size limit for paths is 248 characters"
396
+ // (MAX_PATH - 12, room for an 8.3 file name), that null included; Git for Windows' `mingw_mkdir`
397
+ // converts the path with a limit of 248 where every other call has MAX_PATH).
398
+ // macOS and Linux refuse only NUL and `/` in a name, and neither can come out of a git tree. On every
399
+ // platform a name longer than 255 bytes is refused, the longest name ext4, APFS and NTFS hold, and a
400
+ // path of 1024 bytes or more, the project's own path in front of it included: 1024 is macOS's
401
+ // PATH_MAX, the smallest of the three systems, and counts the terminating null. Without the project's
402
+ // path, a path within the limit below the project still ended in `ENAMETOOLONG` on the host. `git apply`
403
+ // itself writes such a path, working below the project, but the file is then out of reach of anything
404
+ // that opens it by its whole path, the test's own clean-up among them, measured on macOS. Those two
405
+ // are also what keep the check below cheap. Names that differ only in case are one file where the
406
+ // repository says so; see `nameNotAllowedHere`.
407
+ const WINDOWS_RESERVED_CHARACTER = /[<>:"/\\|?*\x01-\x1f]/;
408
+ const WINDOWS_DEVICE_NAME = /^(?:con|prn|aux|nul|conin\$|conout\$|com[1-9¹²³]|lpt[0-9¹²³]) *(?:[.:]|$)/i;
409
+ const WINDOWS_TRAILING = /[ .]$/;
410
+ const WINDOWS_MAX_PATH = 259;
411
+ const WINDOWS_MAX_FOLDER_PATH = 247;
412
+ const MAX_NAME_BYTES = 255;
413
+ const MAX_PATH_BYTES = 1023;
414
+
415
+ /** Why one name cannot exist on `platform`, or null. */
416
+ const nameRule = (name, platform) => {
417
+ if (Buffer.byteLength(name) > MAX_NAME_BYTES) return 'name_too_long';
418
+ if (platform !== 'win32') return null;
419
+ if (WINDOWS_RESERVED_CHARACTER.test(name)) return 'reserved_character';
420
+ if (WINDOWS_DEVICE_NAME.test(name)) return 'device_name';
421
+ if (WINDOWS_TRAILING.test(name)) return 'trailing_space_or_period';
422
+ return null;
423
+ };
424
+
425
+ /**
426
+ * The first path among `created` that cannot exist here, as `{ path, rule, other }`, or null.
427
+ * `created` are the paths a patch adds, `all` every path of the tree it leads to, and `deleted` the
428
+ * paths it removes. Every folder and file name on the way of a created path is checked against the
429
+ * rules above, `top` and `longPaths` giving the whole path's length on Windows.
430
+ *
431
+ * Where the repository compares names without case or Unicode form, a created name is also compared
432
+ * with the other names in its folder: `differs_only_in_case` when another spelling of it stays in the
433
+ * tree, which on this disk would be one file, and `case_only_rename` when the other spelling is one
434
+ * the patch removes, which `git apply` cannot turn into the new one here. A pair of spellings that
435
+ * both existed before the patch is the user's own and passes. Only the folders on the way of created
436
+ * paths are ever held in memory, one node per name, so the cost is the length of the listing and not
437
+ * its square.
438
+ */
439
+ export function nameNotAllowedHere(created, all, { platform = process.platform, ignoreCase = false, precompose = false, deleted = [], top = '', longPaths = false } = {}) {
440
+ const join = platform === 'win32' ? path.win32.join : path.posix.join;
441
+ for (const file of created) {
442
+ const whole = top === '' ? file : join(top, file);
443
+ if (Buffer.byteLength(whole) > MAX_PATH_BYTES) return { path: file, rule: 'path_too_long', other: null };
444
+ if (platform === 'win32' && !longPaths && top !== '') {
445
+ if (whole.length > WINDOWS_MAX_PATH) return { path: file, rule: 'path_too_long', other: null };
446
+ // The folder the file goes into, which apply may have to make, the deepest and longest of them.
447
+ const folder = file.includes('/') ? join(top, file.slice(0, file.lastIndexOf('/'))) : '';
448
+ if (folder.length > WINDOWS_MAX_FOLDER_PATH) return { path: file, rule: 'path_too_long', other: null };
449
+ }
450
+ for (const name of file.split('/')) {
451
+ const rule = nameRule(name, platform);
452
+ if (rule) return { path: file, rule, other: null };
453
+ }
454
+ }
455
+ if ((!ignoreCase && !precompose) || created.length === 0) return null;
456
+ const fold = nameFolder({ ignoreCase, precompose });
457
+ const node = () => ({ children: new Map(), spellings: new Map() });
458
+ const root = node();
459
+ for (const file of created) {
460
+ let at = root;
461
+ for (const name of file.split('/')) {
462
+ const key = fold(name);
463
+ if (!at.children.has(key)) at.children.set(key, node());
464
+ at = at.children.get(key);
465
+ }
466
+ }
467
+ // Records a path's spellings along the created folders it passes through, and stops where it leaves them.
468
+ const mark = (file, present, old) => {
469
+ let at = root;
470
+ let from = 0;
471
+ for (;;) {
472
+ const end = file.indexOf('/', from);
473
+ const name = end === -1 ? file.slice(from) : file.slice(from, end);
474
+ at = at.children.get(fold(name));
475
+ if (!at) return;
476
+ const seen = at.spellings.get(name) ?? { present: false, old: false };
477
+ at.spellings.set(name, { present: seen.present || present, old: seen.old || old });
478
+ if (end === -1) return;
479
+ from = end + 1;
480
+ }
481
+ };
482
+ const isCreated = new Set(created);
483
+ for (const file of all) mark(file, true, !isCreated.has(file));
484
+ for (const file of created) mark(file, true, false);
485
+ for (const file of deleted) mark(file, false, true);
486
+ for (const file of created) {
487
+ const names = file.split('/');
488
+ let at = root;
489
+ for (let depth = 0; depth < names.length; depth += 1) {
490
+ at = at.children.get(fold(names[depth]));
491
+ // A spelling that was already there is the user's, whatever else shares its folded name.
492
+ if (at.spellings.get(names[depth]).old) continue;
493
+ const others = [...at.spellings].filter(([name]) => name !== names[depth]);
494
+ const staying = others.find(([, seen]) => seen.present);
495
+ const going = others.find(([, seen]) => !seen.present);
496
+ const other = staying ?? going;
497
+ if (other) {
498
+ return { path: file, rule: staying ? 'differs_only_in_case' : 'case_only_rename', other: [...names.slice(0, depth), other[0]].join('/') };
499
+ }
500
+ }
501
+ }
502
+ return null;
503
+ }
504
+
505
+ /**
506
+ * The paths of a patch whose added lines hold a conflict marker, from one pass over the patch file.
507
+ * An unfinished merge in a space comes out as content, so this is what the user is about to get in
508
+ * their files. It is a text check and nothing more: a file that talks about merge markers counts too.
509
+ */
510
+ async function conflictedPaths(patch) {
511
+ const paths = [];
512
+ let file = '';
513
+ const lines = readline.createInterface({ input: createReadStream(patch), crlfDelay: Infinity });
514
+ try {
515
+ for await (const text of lines) {
516
+ if (text.startsWith('+++ b/')) file = text.slice('+++ b/'.length);
517
+ else if (file !== '' && text.startsWith('+<<<<<<< ')) {
518
+ paths.push(file);
519
+ file = '';
520
+ }
521
+ }
522
+ } finally {
523
+ lines.close();
524
+ }
525
+ return reported(paths);
526
+ }
527
+
528
+ /**
529
+ * Paths for the caller to show: `count` is how many there are, and `paths` at most
530
+ * `MAX_REPORTED_PATHS` of them with every control character replaced, because a path from a space is
531
+ * text of the space's choosing and lands in a dialog. Counting comes first: a path the space names
532
+ * with a newline must not make the warning disappear.
533
+ */
534
+ const reported = (paths) => {
535
+ const all = [...new Set(paths.filter((entry) => entry !== ''))];
536
+ return { count: all.length, paths: all.slice(0, MAX_REPORTED_PATHS).map((entry) => entry.replace(new RegExp(EXT_UNCARRIABLE, 'g'), '?')) };
537
+ };
538
+
539
+ /**
540
+ * The report of the snapshot script: the id of the commit it made, then `<mode> <object> <stage>\t<path>`
541
+ * for every stage of every unmerged entry of the space's index, NUL separated. A piece without a tab
542
+ * is not one of those and is dropped, a cut last one included.
543
+ */
544
+ const readReport = (text) => {
545
+ const report = String(text ?? '');
546
+ const end = report.indexOf('\n');
547
+ return {
548
+ result: end === -1 ? '' : report.slice(0, end),
549
+ unmerged: reported(report.slice(end + 1).split('\0').filter((entry) => entry.includes('\t')).map((entry) => entry.slice(entry.indexOf('\t') + 1))),
550
+ };
551
+ };
552
+
553
+ /** `{ id, type, size }` for each line of `cat-file --batch-check`, in the order asked. A missing object is a failure. */
554
+ const parseSizes = (text) => text.split('\n').filter(Boolean).map((entry) => {
555
+ const [id, type, size] = entry.split(' ');
556
+ if (type === 'missing' || !/^\d+$/.test(size ?? '')) {
557
+ throw new SpaceError('code_out_failed', `An object of the result is missing from the quarantine: ${id}`, { cause: 'object_missing' });
558
+ }
559
+ return { id, type, size: Number(size) };
560
+ });
561
+
562
+ /**
563
+ * `git` is a host git from `createHostGit`, `place` the place the space lives on. `temporaryDirectory`
564
+ * is where the quarantine repository, the patch and an empty hooks folder live while one call runs.
565
+ * `removeDirectory` is how that folder goes, injectable so a test can make it fail. `readBackTimeoutMs`
566
+ * bounds the time one apply or read of its state spends reading the working tree back, see
567
+ * `READ_BACK_TIMEOUT_MS`. `files` is how the working tree is read back, `node:fs/promises` unless a
568
+ * test counts what is read.
569
+ */
570
+ export function createCodeOut({ git, place, temporaryDirectory = os.tmpdir(), removeDirectory, platform = process.platform, readBackTimeoutMs = READ_BACK_TIMEOUT_MS, files = fs }) {
571
+ const { withHostGit, requireGitVersion, requireWorkTree } = createTransferSession({ git, temporaryDirectory, removeDirectory, name: 'code out' });
572
+
573
+ /** Runs `work`, and turns what it throws into what `bringCodeOut` rejects with, naming `step`. */
574
+ const during = async (step, work) => {
575
+ try {
576
+ return await work();
577
+ } catch (error) {
578
+ if (error instanceof SpaceError && OUT_CODES.has(error.code)) throw error;
579
+ const ours = error instanceof SpaceError;
580
+ const details = { step };
581
+ if (ours) Object.assign(details, error.details);
582
+ details.cause = error.code ?? null;
583
+ throw new SpaceError('code_out_failed', ours ? error.message : `Code out failed on the host: ${error.message}`, details);
584
+ }
585
+ };
586
+
587
+ /** The commit a ref names, read on the host, or null when there is no such ref. */
588
+ const readRef = async (g, where, ref) => {
589
+ const found = await g.run(where, ['rev-parse', '--verify', '--quiet', `${ref}^{object}`]);
590
+ return found.code === 0 ? line(found.stdout) : null;
591
+ };
592
+
593
+ /** The space's start snapshot in this repository, which every comparison is made against. */
594
+ const requireStart = async (g, top, spaceId) => {
595
+ const start = await readRef(g, top, startRef(spaceId));
596
+ if (!start) {
597
+ throw new SpaceError('space_start_missing', `This repository holds no start snapshot for space ${spaceId}. The work of a space can come out only into the repository its code came from.`);
598
+ }
599
+ return start;
600
+ };
601
+
602
+ /** The result that `bringCodeOut` promoted for this space, or a refusal. */
603
+ const requireResult = async (g, top, spaceId) => {
604
+ const result = await readRef(g, top, resultRef(spaceId));
605
+ if (!result) {
606
+ throw new SpaceError('result_missing', `There is no result of space ${spaceId} in this repository yet. Bring the space's work out first.`);
607
+ }
608
+ return result;
609
+ };
610
+
611
+ /**
612
+ * Fetches the space's result into the quarantine, under the host's deadline and a cap on what the
613
+ * quarantine folder holds, polled while the fetch runs. Either ends the fetch's whole process tree,
614
+ * and the rejection says which, whatever exit code the killed tree left: on Windows `taskkill`
615
+ * leaves 1. `--update-shallow` is for this fetch only: while the space's repository is shallow,
616
+ * before its history arrived or after that failed, a fetch without it printed a warning and exited
617
+ * 0 without writing the ref.
618
+ */
619
+ const fetchIntoQuarantine = async (g, quarantine, url, timeoutMs, maxTransferBytes) => {
620
+ const tooLarge = () => new SpaceError(
621
+ 'result_transfer_too_large',
622
+ `The space sent more than ${maxTransferBytes} bytes, the limit of one transfer, so the transfer was stopped. Nothing reached the repository.`,
623
+ { step: 'fetch into the quarantine', limit: maxTransferBytes },
624
+ );
625
+ const controller = new AbortController();
626
+ let finished = false;
627
+ // It never throws: it runs beside the fetch and nothing awaits it until the fetch is over, so a
628
+ // failure of its own would be an unhandled rejection in the server.
629
+ const watch = (async () => {
630
+ try {
631
+ while (!finished) {
632
+ if (await folderBytes(quarantine) > maxTransferBytes) {
633
+ controller.abort(tooLarge());
634
+ return;
635
+ }
636
+ await pause(SIZE_POLL_MS);
637
+ }
638
+ } catch {
639
+ // The cap after the fetch still holds, see below.
640
+ }
641
+ })();
642
+ let fetched;
643
+ try {
644
+ fetched = await g.run(quarantine, [
645
+ '-c', 'protocol.ext.allow=always', ...FETCH_CONFIG, ...QUARANTINE_CONFIG,
646
+ 'fetch', ...FETCH_FLAGS, '--update-shallow', url, `+${INSIDE_RESULT}:${QUARANTINE_RESULT}`,
647
+ ], { timeoutMs, maxOutputBytes: FETCH_MAX_OUTPUT_BYTES, killTree: true, signal: controller.signal });
648
+ } finally {
649
+ finished = true;
650
+ await watch;
651
+ }
652
+ // A fetch that finished between two polls is held to the same cap.
653
+ if (await folderBytes(quarantine) > maxTransferBytes) throw tooLarge();
654
+ if (fetched.code !== 0) {
655
+ // The text comes from the space and from git's checks of its objects. It is shown, never parsed.
656
+ throw new SpaceError('code_out_failed', `Fetching the result out of the space failed: ${tail(fetched.stderr) || `exit code ${fetched.code}`}`, { step: 'fetch into the quarantine', cause: null });
657
+ }
658
+ };
659
+
660
+ /** `{ id, type, size }` by object id, for the ids given, read without inflating anything. */
661
+ const sizesOf = async (g, where, ids) => new Map(parseSizes(await g.output(where, ['cat-file', '--batch-check=%(objectname) %(objecttype) %(objectsize)'], {
662
+ stdin: ids.map((id) => `${id}\n`).join(''),
663
+ maxOutputBytes: LIST_MAX_OUTPUT_BYTES,
664
+ timeoutMs: APPLY_TIMEOUT_MS,
665
+ })).map((object) => [object.id, object]));
666
+
667
+ /** The raw `diff-tree -z` listing of what `to` changes against `from`, as fields: the status line, then the path. */
668
+ const listChange = async (g, where, from, to) => (await g.output(where, ['diff-tree', '-r', '-z', '--no-renames', from, to], { maxOutputBytes: LIST_MAX_OUTPUT_BYTES, timeoutMs: APPLY_TIMEOUT_MS })).split('\0');
669
+
670
+ /**
671
+ * What one tree changes against another, counted and sized without inflating anything: the paths,
672
+ * the folders they pass through, the bytes a patch between the two would read and write, which is
673
+ * both sides of every changed path, deletions included, and the gitlinks among them. `where` is the
674
+ * repository that holds both, the quarantine for the result against the start and the user's own
675
+ * for an apply. `maxFolders` and `maxFolderSteps` are the caps on the folders, see below.
676
+ */
677
+ const measureChange = async (g, where, from, to, { maxChangedBytes, maxChangedEntries, maxFolders, maxFolderSteps }, refuse, list = () => listChange(g, where, from, to)) => {
678
+ let fields;
679
+ try {
680
+ fields = await list();
681
+ } catch (error) {
682
+ if (error.code === 'command_output_too_large') throw refuse.tooMany(`changes so many paths that their list is longer than ${LIST_MAX_OUTPUT_BYTES} bytes`, LIST_MAX_OUTPUT_BYTES);
683
+ throw error;
684
+ }
685
+ const touched = [];
686
+ const nested = [];
687
+ const paths = [];
688
+ const created = [];
689
+ const deleted = [];
690
+ const deletedLinks = [];
691
+ let changedPaths = 0;
692
+ // The folders the changed paths pass through, a cost the paths and bytes do not show. Twenty
693
+ // thousand files five hundred folders deep brought only 503 objects and passed every other cap,
694
+ // and the checks before an apply then ran Node out of memory. Two counts, both in this one pass:
695
+ // - `folders`, each folder once: the host keeps a node for each while it checks names and links,
696
+ // so this is what bounds its memory;
697
+ // - `steps`, how deep each of them and each path lies: every look at a folder and every file git
698
+ // writes goes from the top of the work tree down, one folder at a time, so this is what bounds
699
+ // the time. Measured on macOS under the folder cap alone: 95,520 files in 95,520 folders, 480
700
+ // deep, took the checks 173 s and `git apply` 148 s for its dry run and 307 s for the apply.
701
+ // diff-tree lists the paths of one folder together, so a folder is counted when a path enters it
702
+ // and not again for the paths inside it that follow. A listing in another order would count a
703
+ // folder again, which refuses sooner and never lets more through. Counting holds one path.
704
+ let folders = 0;
705
+ let steps = 0;
706
+ let previous = [];
707
+ for (let index = 0; index + 1 < fields.length; index += 2) {
708
+ const [oldMode, newMode, oldId, newId, status] = fields[index].slice(1).split(' ');
709
+ const file = fields[index + 1];
710
+ changedPaths += 1;
711
+ if (changedPaths > maxChangedEntries) throw refuse.tooMany(`changes more than ${maxChangedEntries} paths`, maxChangedEntries);
712
+ const names = file.split('/').slice(0, -1);
713
+ let shared = 0;
714
+ while (shared < names.length && shared < previous.length && names[shared] === previous[shared]) shared += 1;
715
+ folders += names.length - shared;
716
+ // The depths of the folders entered here, shared + 1 to names.length, and the path's own.
717
+ steps += (names.length * (names.length + 1) - shared * (shared + 1)) / 2 + names.length;
718
+ if (folders > maxFolders) throw refuse.tooMany(`changes files in more than ${maxFolders} folders`, maxFolders);
719
+ if (steps > maxFolderSteps) throw refuse.tooMany(`changes files so deep among so many folders that reaching them all takes more than ${maxFolderSteps} steps from a folder into the next`, maxFolderSteps);
720
+ previous = names;
721
+ paths.push(file);
722
+ if (status === 'A') created.push(file);
723
+ if (status === 'D') deleted.push(file);
724
+ if (status === 'D' && oldMode === '120000') deletedLinks.push(file);
725
+ // A gitlink names a commit that never travels: a repository the agent made inside its project.
726
+ if (status !== 'D' && newMode === '160000') nested.push(file);
727
+ // Both sides count. A patch reads what is there now, a deletion included, and writes what comes:
728
+ // measured with git 2.50.1, deleting one 431 MB file cost `diff-tree` 1.09 GB and `apply` 1.67 GB,
729
+ // while the deletion itself brings no object at all.
730
+ if (oldMode !== '160000' && !/^0+$/.test(oldId)) touched.push(oldId);
731
+ if (status !== 'D' && newMode !== '160000') touched.push(newId);
732
+ }
733
+ const sizes = await sizesOf(g, where, [...new Set(touched)]);
734
+ const changedBytes = touched.reduce((sum, id) => sum + sizes.get(id).size, 0);
735
+ if (changedBytes > maxChangedBytes) throw refuse.tooLarge(changedBytes, maxChangedBytes);
736
+ return { changedPaths, changedBytes, nestedRepositories: reported(nested), paths, created, deleted, deletedLinks };
737
+ };
738
+
739
+ /**
740
+ * Everything the result's history brings that the user does not have, counted and sized: no object
741
+ * of it may be larger than `MAX_OBJECT_BYTES` unless it is a file, and together they must stay
742
+ * within `maxChangedBytes`. A commit message and a tree weigh as much as a file and travel in a
743
+ * pack of a few kilobytes, so neither the transfer cap nor the diff sees them. Runs in the
744
+ * quarantine, which sees the start through its alternates, so nothing of this enters the user's
745
+ * repository.
746
+ */
747
+ const measureHistory = async (g, quarantine, start, result, { maxChangedBytes, maxChangedEntries }, refuse) => {
748
+ let fresh;
749
+ try {
750
+ fresh = (await g.output(quarantine, ['rev-list', '--objects', '--no-object-names', result, '--not', start], { maxOutputBytes: LIST_MAX_OUTPUT_BYTES, timeoutMs: APPLY_TIMEOUT_MS })).split('\n').filter(Boolean);
751
+ } catch (error) {
752
+ if (error.code === 'command_output_too_large') throw refuse.tooMany(`brings so many objects that their list is longer than ${LIST_MAX_OUTPUT_BYTES} bytes`, LIST_MAX_OUTPUT_BYTES);
753
+ throw error;
754
+ }
755
+ if (fresh.length > maxChangedEntries) throw refuse.tooMany(`brings more than ${maxChangedEntries} objects`, maxChangedEntries);
756
+ const sizes = await sizesOf(g, quarantine, fresh);
757
+ let newBytes = 0;
758
+ for (const id of fresh) {
759
+ const object = sizes.get(id);
760
+ newBytes += object.size;
761
+ if (object.type !== 'blob' && object.size > MAX_OBJECT_BYTES) {
762
+ throw new SpaceError(
763
+ 'result_too_large',
764
+ `The work of the space holds a ${object.type} of ${object.size} bytes, more than the ${MAX_OBJECT_BYTES} bytes one of those may hold. Nothing reached the repository.`,
765
+ { step: 'measure the result', limit: MAX_OBJECT_BYTES, objectType: object.type, objectBytes: object.size },
766
+ );
767
+ }
768
+ }
769
+ if (newBytes > maxChangedBytes) throw refuse.tooLarge(newBytes, maxChangedBytes);
770
+ return { newBytes };
771
+ };
772
+
773
+ /**
774
+ * The commit a fetch wrote to `ref` in `where`, read on the host and never taken from what the space
775
+ * printed. A fetch can exit 0 without writing its ref: a shallow source it may not follow prints a
776
+ * warning and nothing else. So a missing ref, or one that does not hold `expected`, is a failure.
777
+ */
778
+ const requireFetched = async (g, where, ref, step, expected) => {
779
+ const found = await readRef(g, where, ref);
780
+ if (!found || found !== expected) {
781
+ throw new SpaceError('result_ref_missing', 'The result did not arrive, or is not the one the space said it made. Nothing was changed.', { step, cause: null });
782
+ }
783
+ return found;
784
+ };
785
+
786
+ /**
787
+ * Brings the space's work out. Inside, a fixed script makes `refs/openchamber/result`: HEAD, with
788
+ * one more commit on top that holds whatever is uncommitted, when something is. The agent's working
789
+ * tree and index stay as they were. On the host, that result is fetched into a throwaway quarantine
790
+ * repository with object checks, sized there against this space's start, and only then fetched
791
+ * into the user's repository as `refs/openchamber/spaces/<id>/result`, which moves on a later call.
792
+ * The quarantine goes on every path.
793
+ *
794
+ * `spacePath` is the one `bringCodeIn` returned. Resolves `{ result, changedPaths, changedBytes,
795
+ * nestedRepositories, unmerged }`. The last two are what the caller has to warn about: repositories
796
+ * the agent made inside its project, which arrive as a gitlink and nothing else, and paths the agent
797
+ * left in a conflicted merge, whose conflict markers are in the result as ordinary content. Each is
798
+ * `{ count, paths }`, with at most a hundred paths.
799
+ */
800
+ const bringCodeOut = (request) => withHostGit('code_out_failed', async (g, directory) => {
801
+ const {
802
+ repository, spaceId, spacePath: requestedPath, timeoutMs = FETCH_TIMEOUT_MS, innerMarginSeconds = INNER_MARGIN_SECONDS,
803
+ maxTransferBytes = MAX_TRANSFER_BYTES, maxChangedBytes = MAX_CHANGED_BYTES, maxChangedEntries = MAX_CHANGED_ENTRIES,
804
+ } = request ?? {};
805
+ requireSpaceId(spaceId);
806
+ const spacePath = requireSpaceProjectPath(spaceId, requestedPath);
807
+ requireTimeout(timeoutMs);
808
+ requireInnerMargin(innerMarginSeconds);
809
+ const limits = { maxTransferBytes, maxChangedBytes, maxChangedEntries };
810
+ for (const name of LIMIT_NAMES) requireLimit(limits[name], name);
811
+ await requireGitVersion(g, directory);
812
+ const top = await requireWorkTree(g, repository);
813
+ const start = await requireStart(g, top, spaceId);
814
+ const objectFormat = line(await g.output(top, ['rev-parse', '--show-object-format']));
815
+
816
+ // Asked first: it refuses a space that is gone, stopped or not ours before anything runs inside.
817
+ const execArgv = await during('reach the space', () => place.execArgv(spaceId));
818
+ const reportedByTheSpace = await during('snapshot the space', async () => {
819
+ const seconds = innerSeconds(SNAPSHOT_TIMEOUT_MS, innerMarginSeconds);
820
+ const snapshot = await place.exec(spaceId, [
821
+ IMAGE_TIMEOUT, '-s', 'KILL', String(seconds), IMAGE_SH, '-c', SNAPSHOT_SCRIPT, 'sh',
822
+ spacePath, UNCOMMITTED_MESSAGE, SNAPSHOT_IDENTITY.GIT_COMMITTER_NAME, SNAPSHOT_IDENTITY.GIT_COMMITTER_EMAIL,
823
+ ], { timeoutMs: SNAPSHOT_TIMEOUT_MS });
824
+ if (snapshot.code !== 0) {
825
+ throw new SpaceError('code_out_failed', `Could not take the snapshot inside the space: ${tail(snapshot.stderr) || `exit code ${snapshot.code}`}`, { step: 'snapshot the space', cause: 'inside_command_failed', exitCode: snapshot.code });
826
+ }
827
+ const report = readReport(snapshot.stdout);
828
+ if (!objectIdPattern(objectFormat).test(report.result)) {
829
+ throw new SpaceError('code_out_failed', 'The space did not say which commit its snapshot made.', { step: 'snapshot the space', cause: 'inside_command_failed' });
830
+ }
831
+ return report;
832
+ });
833
+
834
+ // The quarantine reads the user's objects through alternates and copies none, so the space sends
835
+ // only what the user does not have, and a failed or killed fetch leaves its partial pack here.
836
+ const quarantine = path.join(directory, 'quarantine.git');
837
+ const result = await during('fetch into the quarantine', async () => {
838
+ await g.output(directory, ['init', '--quiet', '--bare', '--template=', `--object-format=${objectFormat}`, quarantine]);
839
+ const objects = path.resolve(top, line(await g.output(top, ['rev-parse', '--git-path', 'objects'])));
840
+ await fs.mkdir(path.join(quarantine, 'objects', 'info'), { recursive: true });
841
+ await fs.writeFile(path.join(quarantine, 'objects', 'info', 'alternates'), `${objects.replaceAll('\\', '/')}\n`);
842
+ const url = buildExtUrl([...execArgv, IMAGE_TIMEOUT, '-s', 'KILL', String(innerSeconds(timeoutMs, innerMarginSeconds)), IMAGE_GIT, 'upload-pack', spacePath]);
843
+ await fetchIntoQuarantine(g, quarantine, url, timeoutMs, maxTransferBytes);
844
+ // Exactly the commit the snapshot said it made. Between the snapshot and the fetch the space can
845
+ // move its own ref, and everything the host reports about unmerged paths belongs to that one commit.
846
+ const fetched = await requireFetched(g, quarantine, QUARANTINE_RESULT, 'fetch into the quarantine', reportedByTheSpace.result);
847
+ if (line(await g.output(quarantine, ['cat-file', '-t', fetched])) !== 'commit') {
848
+ throw new SpaceError('result_not_a_commit', 'What the space offered as its result is not a commit. Nothing was changed.', { step: 'fetch into the quarantine' });
849
+ }
850
+ return fetched;
851
+ });
852
+ const summary = await during('measure the result', async () => {
853
+ const refuse = {
854
+ tooMany: (what, limit) => new SpaceError('result_too_many_changes', `The work of the space ${what}, the limit of one result. Nothing reached the repository.`, { step: 'measure the result', limit }),
855
+ tooLarge: (bytes, limit) => new SpaceError('result_too_large', `The work of the space brings ${bytes} bytes, more than the limit of ${limit}. Nothing reached the repository.`, { step: 'measure the result', limit, newBytes: bytes }),
856
+ };
857
+ // What the history brings is what the user's repository keeps, so it is what is capped here.
858
+ // What a patch of it would cost, its bytes and its folders, is measured again by
859
+ // `applyAsChanges`, where it is paid; here neither is refused, so the branch holds such work.
860
+ const { newBytes } = await measureHistory(g, quarantine, start, result, limits, refuse);
861
+ const { paths, created, deleted, deletedLinks, ...change } = await measureChange(g, quarantine, start, result, {
862
+ ...limits, maxChangedBytes: Number.MAX_SAFE_INTEGER, maxFolders: Number.MAX_SAFE_INTEGER, maxFolderSteps: Number.MAX_SAFE_INTEGER,
863
+ }, refuse);
864
+ return { ...change, newBytes };
865
+ });
866
+
867
+ // Into the user's repository, from the quarantine, with the same checks and without
868
+ // `--update-shallow`: the user's repository never becomes shallow.
869
+ await during('promote the result', async () => {
870
+ const promoted = await g.run(top, [
871
+ ...FETCH_CONFIG, 'fetch', ...FETCH_FLAGS, quarantine.replaceAll('\\', '/'), `+${QUARANTINE_RESULT}:${resultRef(spaceId)}`,
872
+ ], { timeoutMs, maxOutputBytes: FETCH_MAX_OUTPUT_BYTES, killTree: true });
873
+ if (promoted.code !== 0) {
874
+ throw new SpaceError('code_out_failed', `Taking the result into the repository failed: ${tail(promoted.stderr) || `exit code ${promoted.code}`}`, { step: 'promote the result', cause: null });
875
+ }
876
+ await requireFetched(g, top, resultRef(spaceId), 'promote the result', result);
877
+ });
878
+ return { result, ...summary, unmerged: reportedByTheSpace.unmerged };
879
+ });
880
+
881
+ /** A branch name git accepts, taken as it is: `check-ref-format --branch` also expands `@{-1}`, which must not pass. */
882
+ const requireBranchName = async (g, top, branch) => {
883
+ const text = String(branch ?? '');
884
+ const refused = () => new SpaceError('invalid_branch_name', 'That name is not one git accepts for a new branch.');
885
+ // Only the name as it was given: a number or anything else that turns into text is refused.
886
+ if (text !== branch || text === '' || text.startsWith('-') || EXT_UNCARRIABLE.test(text)) throw refused();
887
+ const check = await g.run(top, ['check-ref-format', '--branch', text]);
888
+ if (check.code !== 0 || line(check.stdout) !== text) throw refused();
889
+ return text;
890
+ };
891
+
892
+ /**
893
+ * Makes the new branch `refs/heads/<branch>` at the result, with the agent's commits and, on top,
894
+ * the uncommitted changes of the space as one commit when there were any. Nothing is checked out:
895
+ * the working tree, the index and HEAD stay as they are, and no program of the user's runs. An
896
+ * existing branch is refused and never moved. Resolves `{ branch, commit }`.
897
+ */
898
+ const applyAsBranch = (request) => withHostGit('code_out_failed', async (g, directory) => {
899
+ const { repository, spaceId, branch } = request ?? {};
900
+ requireSpaceId(spaceId);
901
+ await requireGitVersion(g, directory);
902
+ const top = await requireWorkTree(g, repository);
903
+ const name = await requireBranchName(g, top, branch);
904
+ const result = await requireResult(g, top, spaceId);
905
+ const objectFormat = line(await g.output(top, ['rev-parse', '--show-object-format']));
906
+ const exists = () => new SpaceError('branch_exists', `A branch named ${name} already exists. Choose another name; the existing branch was not changed.`);
907
+ if (await readRef(g, top, `refs/heads/${name}`)) throw exists();
908
+ // Against the zero id, so a branch that appeared in between is never moved.
909
+ const created = await g.run(top, ['update-ref', '-m', `openchamber: work of space ${spaceId}`, `refs/heads/${name}`, result, zeroObjectId(objectFormat)]);
910
+ if (created.code !== 0) {
911
+ if (await readRef(g, top, `refs/heads/${name}`)) throw exists();
912
+ const clash = await branchInTheWay(g, top, name);
913
+ if (clash !== null) {
914
+ throw new SpaceError('branch_exists', `A branch named ${clash} already exists, and git cannot keep a branch named ${name} beside it. Choose another name; the existing branch was not changed.`, { branch: clash });
915
+ }
916
+ throw new SpaceError('git_command_failed', `Could not create the branch ${name}: ${tail(created.stderr) || `exit code ${created.code}`}`, { exitCode: created.code });
917
+ }
918
+ return { branch: name, commit: result };
919
+ });
920
+
921
+ /**
922
+ * A branch that keeps `name` from being made, or null: git keeps a branch as a file under the name's
923
+ * folders, so `feature/x` cannot be made beside a branch `feature`, nor `topic` beside `topic/one`.
924
+ */
925
+ const branchInTheWay = async (g, top, name) => {
926
+ const folders = name.split('/').slice(0, -1);
927
+ for (let depth = 1; depth <= folders.length; depth += 1) {
928
+ const above = folders.slice(0, depth).join('/');
929
+ if (await readRef(g, top, `refs/heads/${above}`)) return above;
930
+ }
931
+ const below = line(await g.output(top, ['for-each-ref', '--count=1', '--format=%(refname)', `refs/heads/${name}/`]));
932
+ return below === '' ? null : below.slice('refs/heads/'.length);
933
+ };
934
+
935
+ /** A boolean setting of this repository, false when it is not set. */
936
+ const readSetting = async (g, top, key) => line((await g.run(top, ['config', '--bool', '--get', key])).stdout) === 'true';
937
+
938
+ const exists = (full) => fs.lstat(full).then(() => true, () => false);
939
+
940
+ /** Writes the patch from `from` to `to` into `file`, with git's plumbing and never the user's diff settings. */
941
+ const writePatch = (g, top, from, to, file) => g.output(top, [...PATCH_ARGS, `--output=${file}`, from, to], { timeoutMs: APPLY_TIMEOUT_MS });
942
+
943
+ /**
944
+ * Whether `patch` applies to the working tree as it is now, or backwards with `reverse`: the dry run
945
+ * and nothing else. Only the end of what it prints is kept, so its answer never depends on how much
946
+ * it has to say. It reads the files through the user's clean filters, which run as children of git, so
947
+ * a timeout ends its whole tree.
948
+ */
949
+ const dryRun = (g, top, patch, { reverse = false } = {}) => g.run(top, [...APPLY_ARGS, '--check', ...(reverse ? ['--reverse'] : []), patch], {
950
+ timeoutMs: APPLY_TIMEOUT_MS, maxOutputBytes: APPLY_OUTPUT_TAIL_BYTES, keepTail: true, killTree: true,
951
+ });
952
+
953
+ /**
954
+ * One call of `applyAsChanges` or `describeApplyState`: where it works, what it already asked git,
955
+ * and until when it may read the working tree back. A pair of trees is listed once per call, and a
956
+ * patch built and dry-run once, because nothing of the working tree changes before the real apply:
957
+ * sorting out an interrupted apply and the apply after it usually ask about the same pair. For the
958
+ * same reason each path of the working tree is read once per call, `readings`, and the folders on the
959
+ * way to it looked at once, `folders`.
960
+ */
961
+ const startCall = (g, directory, top) => ({
962
+ g, directory, top, until: Date.now() + readBackTimeoutMs, listings: new Map(), patches: new Map(), files: 0,
963
+ settings: null, readings: new Map(), folders: new Map(), commits: new Map(),
964
+ });
965
+
966
+ /**
967
+ * What a read-back that did not end in time throws: nothing is known then, and nothing is guessed.
968
+ * With `failed`, why reading back failed outright, which is answered the same way: what git said,
969
+ * for a clean filter of the user's that fails on a file, which would otherwise fail every later
970
+ * call, or the sentence `hashFile` could not read a file with.
971
+ */
972
+ const undecided = (failed = null) => new SpaceError(
973
+ 'changes_undecided',
974
+ failed === null
975
+ ? `Reading your project back took longer than ${Math.round(readBackTimeoutMs / 1000)} seconds, so it is not known how much of what was last applied from this space is still there.`
976
+ : `Reading your project back failed, so it is not known how much of what was last applied from this space is still there. ${failed}`,
977
+ failed === null ? { limitMs: readBackTimeoutMs } : { limitMs: readBackTimeoutMs, cause: 'read_back_failed' },
978
+ );
979
+
980
+ /**
981
+ * Host git for reading the working tree back, within the time of the call: each command gets what is
982
+ * left of it, and one that runs out of it, or prints more than the host reads, is `changes_undecided`.
983
+ * Without the bound, a tree that is slow to read failed every later call the same way: measured by a
984
+ * reviewer, past about 6 ms a file at a hundred thousand files the comparison hit the apply's deadline.
985
+ * Each ends with its whole tree, `killTree`: git starts the user's filters as children of its own, and
986
+ * a reviewer found one orphaned per call when only git was killed, a filter that hangs left for good.
987
+ */
988
+ const readingBack = (call) => {
989
+ const bounded = (method) => async (where, args, options = {}) => {
990
+ const left = call.until - Date.now();
991
+ if (left <= 0) throw undecided();
992
+ try {
993
+ return await call.g[method](where, args, { ...options, killTree: true, timeoutMs: Math.min(options.timeoutMs ?? APPLY_TIMEOUT_MS, left) });
994
+ } catch (error) {
995
+ if (error.code === 'command_timeout' || error.code === 'command_output_too_large') throw undecided();
996
+ throw error;
997
+ }
998
+ };
999
+ return { run: bounded('run'), output: bounded('output') };
1000
+ };
1001
+
1002
+ /** The listing of what `to` changes against `from`, asked once per call. */
1003
+ const listed = async (call, g, from, to) => {
1004
+ const key = `${from} ${to}`;
1005
+ if (!call.listings.has(key)) call.listings.set(key, await listChange(g, call.top, from, to));
1006
+ return call.listings.get(key);
1007
+ };
1008
+
1009
+ /** The patch from `from` to `to` in the call's folder, built once per call, `{ file, check }`. */
1010
+ const patchOf = async (call, g, from, to) => {
1011
+ const key = `${from} ${to}`;
1012
+ if (!call.patches.has(key)) {
1013
+ call.files += 1;
1014
+ const file = path.join(call.directory, `change-${call.files}.patch`).replaceAll('\\', '/');
1015
+ await writePatch(g, call.top, from, to, file);
1016
+ call.patches.set(key, { file, check: null });
1017
+ }
1018
+ return call.patches.get(key);
1019
+ };
1020
+
1021
+ /** The dry run of the patch from `from` to `to`, run once per call. */
1022
+ const checkOf = async (call, g, from, to) => {
1023
+ const patch = await patchOf(call, g, from, to);
1024
+ patch.check ??= await dryRun(g, call.top, patch.file);
1025
+ return patch.check;
1026
+ };
1027
+
1028
+ const ABSENT = '000000';
1029
+ const GITLINK = '160000';
1030
+ const LINK = '120000';
1031
+ const EXECUTABLE = '100755';
1032
+ const isRegular = (side) => side.mode === '100644' || side.mode === EXECUTABLE;
1033
+
1034
+ /** A boolean setting of this repository, or `fallback` when it is not set. */
1035
+ const readFlag = async (g, top, key, fallback) => {
1036
+ const found = await g.run(top, ['config', '--bool', '--get', key]);
1037
+ return found.code === 0 ? line(found.stdout) === 'true' : fallback;
1038
+ };
1039
+
1040
+ /**
1041
+ * What reading this working tree back depends on, asked once per call: the hash the repository names
1042
+ * objects with, and whether git here sees the exec bit (`core.filemode`) and keeps links as links
1043
+ * (`core.symlinks`), both true unless the repository says otherwise, as git itself takes them.
1044
+ */
1045
+ const settingsOf = async (call) => {
1046
+ call.settings ??= {
1047
+ algorithm: line(await call.g.output(call.top, ['rev-parse', '--show-object-format'])),
1048
+ filemode: await readFlag(call.g, call.top, 'core.filemode', true),
1049
+ symlinks: await readFlag(call.g, call.top, 'core.symlinks', true),
1050
+ };
1051
+ return call.settings;
1052
+ };
1053
+
1054
+ /**
1055
+ * The paths of the change from `from` to `to` that a working tree can hold apart, each with both
1056
+ * sides, `{ path, from: { mode, id }, to: { mode, id } }`. A gitlink never travels, so a path with one
1057
+ * on either side has nothing to compare and is left out, and so is a change of the exec bit alone
1058
+ * where git here does not see the exec bit: the two sides are the same file on this disk.
1059
+ */
1060
+ const comparedPaths = async (call, g, from, to) => {
1061
+ const { filemode } = await settingsOf(call);
1062
+ const fields = await listed(call, g, from, to);
1063
+ const entries = [];
1064
+ for (let index = 0; index + 1 < fields.length; index += 2) {
1065
+ const [oldMode, newMode, oldId, newId] = fields[index].slice(1).split(' ');
1066
+ if (oldMode === GITLINK || newMode === GITLINK) continue;
1067
+ if (!filemode && oldId === newId && oldMode !== LINK && newMode !== LINK) continue;
1068
+ entries.push({ path: fields[index + 1], from: { mode: oldMode, id: oldId }, to: { mode: newMode, id: newId } });
1069
+ }
1070
+ return entries;
1071
+ };
1072
+
1073
+ /**
1074
+ * Whether every folder on the way to `file` is a plain folder of the work tree, each looked at once
1075
+ * per call. Below anything else, a link to another place included, `git status` reads the path as
1076
+ * absent, and so does this: nothing is read through a link.
1077
+ */
1078
+ const plainWay = async (call, file) => {
1079
+ let relative = '';
1080
+ for (const name of file.split('/').slice(0, -1)) {
1081
+ relative = relative === '' ? name : `${relative}/${name}`;
1082
+ if (!call.folders.has(relative)) {
1083
+ call.folders.set(relative, await files.lstat(path.join(call.top, ...relative.split('/'))).then((stats) => stats.isDirectory(), () => false));
1084
+ }
1085
+ if (!call.folders.get(relative)) return false;
1086
+ }
1087
+ return true;
1088
+ };
1089
+
1090
+ /**
1091
+ * What the working tree holds at `file`, read by the host itself, with no git and no filter:
1092
+ * `{ kind, executable, raw, clean }`. `kind` is `absent`, `file`, `link` or `other`; `raw` the id the
1093
+ * bytes there would have as a blob, a link's target for a link; `clean` is left for `cleanIds`. A file
1094
+ * is read a piece at a time and never held whole, and nothing is written anywhere.
1095
+ */
1096
+ const lookAt = async (call, file) => {
1097
+ if (call.readings.has(file)) return call.readings.get(file);
1098
+ const { algorithm } = await settingsOf(call);
1099
+ const full = path.join(call.top, ...file.split('/'));
1100
+ let reading = { kind: 'absent' };
1101
+ if (await plainWay(call, file)) {
1102
+ const stats = await files.lstat(full).catch(() => null);
1103
+ if (stats?.isSymbolicLink()) {
1104
+ const target = await files.readlink(full, { encoding: 'buffer' }).catch(() => null);
1105
+ reading = target === null ? { kind: 'other' } : { kind: 'link', raw: blobId(algorithm, target) };
1106
+ } else if (stats?.isFile()) {
1107
+ let raw = CHANGED_WHILE_READ;
1108
+ try {
1109
+ // A file that changed while it was read, as when the user's editor saves it at that moment, is
1110
+ // read again, a few times, before nothing is known of it.
1111
+ for (let read = 0; raw === CHANGED_WHILE_READ && read < READS_OF_A_CHANGING_FILE; read += 1) {
1112
+ if (read > 0) await pause(PAUSE_BETWEEN_READS_MS);
1113
+ raw = await hashFile(files, full, algorithm, call.until);
1114
+ }
1115
+ } catch (error) {
1116
+ if (error.code === 'command_timeout') throw undecided();
1117
+ throw error;
1118
+ }
1119
+ // Something else than the plain file `lstat` saw, or a read that failed: never handed to git
1120
+ // instead, which would follow a link or wait on a FIFO. Nothing is known then.
1121
+ if (raw === null) throw undecided(`${reported([file]).paths[0]} could not be read as a plain file.`);
1122
+ if (raw === CHANGED_WHILE_READ) throw undecided(`${reported([file]).paths[0]} kept changing while it was read.`);
1123
+ reading = { kind: 'file', executable: (stats.mode & 0o100) !== 0, raw, clean: undefined };
1124
+ } else if (stats) {
1125
+ reading = { kind: 'other' };
1126
+ }
1127
+ }
1128
+ call.readings.set(file, reading);
1129
+ return reading;
1130
+ };
1131
+
1132
+ /**
1133
+ * Fills in `clean` for each of `paths` that is a file: its id as git reads it back through the user's
1134
+ * clean filters and line endings, the way `git status` compares, from one `hash-object --stdin-paths`
1135
+ * that streams the answers. The attributes are exactly the ones of the working tree as it is now, with
1136
+ * the user's global and `info/attributes` files: git would fall back to the index for a folder whose
1137
+ * `.gitattributes` is missing, so it is given an index that does not exist. Nothing is written. A name
1138
+ * that the line-by-line input cannot carry, one with a line break or starting with a quote, is asked
1139
+ * on its own.
1140
+ */
1141
+ const cleanIds = async (call, g, paths) => {
1142
+ const env = { GIT_INDEX_FILE: path.join(call.directory, 'no-index') };
1143
+ const { algorithm } = await settingsOf(call);
1144
+ const idLength = algorithm === 'sha256' ? 64 : 40;
1145
+ const asked = paths.filter((file) => call.readings.get(file)?.kind === 'file' && call.readings.get(file).clean === undefined);
1146
+ const odd = (file) => /[\n\r]/.test(file) || file.startsWith('"');
1147
+ const together = asked.filter((file) => !odd(file));
1148
+ const answer = async (args, stdin, count) => {
1149
+ // A chatty filter prints to stderr for every file: only the end of that is kept, never failing.
1150
+ const done = await g.run(call.top, args, { env, stdin, maxOutputBytes: count * (idLength + 1) + APPLY_OUTPUT_TAIL_BYTES, keepTail: true });
1151
+ const ids = done.stdout.split('\n').filter(Boolean);
1152
+ if (done.code !== 0 || ids.length !== count) throw undecided(`Git said: ${tail(done.stderr) || `exit code ${done.code}`}`);
1153
+ return ids;
1154
+ };
1155
+ if (together.length > 0) {
1156
+ const ids = await answer(['hash-object', '--stdin-paths'], together.map((file) => `${file}\n`).join(''), together.length);
1157
+ together.forEach((file, index) => { call.readings.get(file).clean = ids[index]; });
1158
+ }
1159
+ for (const file of asked.filter(odd)) {
1160
+ [call.readings.get(file).clean] = await answer(['hash-object', '--', file], '', 1);
1161
+ }
1162
+ };
1163
+
1164
+ /** Whether `reading`'s kind and exec bit fit `side`, as git here compares them. */
1165
+ const fits = (call, reading, side) => {
1166
+ if (side.mode === ABSENT) return reading.kind === 'absent';
1167
+ if (side.mode === LINK) return reading.kind === 'link' || (!call.settings.symlinks && reading.kind === 'file');
1168
+ return reading.kind === 'file' && (!call.settings.filemode || reading.executable === (side.mode === EXECUTABLE));
1169
+ };
1170
+ const holdsRaw = (call, reading, side) => fits(call, reading, side) && (side.mode === ABSENT || reading.raw === side.id);
1171
+ const holdsClean = (call, reading, side) => isRegular(side) && fits(call, reading, side) && reading.clean === side.id;
1172
+
1173
+ /** `lookAt` for the path of each of `entries`, a few at once; the first failure stops the rest from starting. */
1174
+ const lookAtAll = async (call, entries) => {
1175
+ let failed = null;
1176
+ let next = 0;
1177
+ const worker = async () => {
1178
+ while (failed === null && next < entries.length) {
1179
+ const entry = entries[next];
1180
+ next += 1;
1181
+ try {
1182
+ await lookAt(call, entry.path);
1183
+ } catch (error) {
1184
+ failed ??= error;
1185
+ }
1186
+ }
1187
+ };
1188
+ await Promise.all(Array.from({ length: Math.min(READ_AT_ONCE, entries.length) }, worker));
1189
+ if (failed !== null) throw failed;
1190
+ };
1191
+
1192
+ /**
1193
+ * Which side of its change the working tree holds at each of `entries`: `to`, `from` or `neither`.
1194
+ * The bytes decide first: a file that is byte for byte one side's blob holds that side, `to` before
1195
+ * `from`, which is what `git apply` wrote or `git restore` put back where no filter changes a byte, and
1196
+ * costs no git at all. Only a file that is neither side's bytes is read the way `git status` reads it,
1197
+ * through the user's clean filters and line endings, see `cleanIds`: a file an LFS-like filter keeps,
1198
+ * or one the user's line endings changed. Nothing is written, and no smudge filter runs. Each look at
1199
+ * a file checks the time the call has left.
1200
+ */
1201
+ const sidesHeld = async (call, g, entries) => {
1202
+ await lookAtAll(call, entries);
1203
+ const reading = (entry) => call.readings.get(entry.path);
1204
+ await cleanIds(call, g, entries
1205
+ .filter((entry) => (isRegular(entry.to) || isRegular(entry.from)) && !holdsRaw(call, reading(entry), entry.to) && !holdsRaw(call, reading(entry), entry.from))
1206
+ .map((entry) => entry.path));
1207
+ return entries.map((entry) => {
1208
+ if (holdsRaw(call, reading(entry), entry.to)) return 'to';
1209
+ if (holdsRaw(call, reading(entry), entry.from)) return 'from';
1210
+ if (holdsClean(call, reading(entry), entry.to)) return 'to';
1211
+ if (holdsClean(call, reading(entry), entry.from)) return 'from';
1212
+ return 'neither';
1213
+ });
1214
+ };
1215
+
1216
+ /**
1217
+ * Which of `paths` a commit of the user's changed since the last apply: a commit reachable from
1218
+ * `head`, this working tree's HEAD, and not from `since`, the HEAD the last apply recorded, each a
1219
+ * commit or null for none yet. A move back, a reset to before or a checkout of an older commit, adds
1220
+ * no such commit, so the paths it put back count as thrown away; a revert is a new commit and counts
1221
+ * as the user's change. Another linked worktree or another branch compares the same way: only commits
1222
+ * the last apply's HEAD does not have count.
1223
+ *
1224
+ * One `git log --name-only` over that range and every path, once per call and range, `call.commits`:
1225
+ * each question of the call, the look at every apply and the look at the last one, is answered from
1226
+ * that one list. Limited to the paths asked, as the eighth round did, git compared each commit against
1227
+ * each of them: measured, 74 s for a hundred thousand paths over ten thousand commits, and past the
1228
+ * five minutes for commits that touched them, against 2.9 s and 0.3 s for the whole list. It is bounded
1229
+ * by the call's time and the 64 MiB listing cap, past which the call is `changes_undecided`, and it
1230
+ * prints as it walks, so a host that dies stops it at its next write. No history is simplified: a
1231
+ * commit on a side branch that a merge brought counts even where the merge dropped its change, which
1232
+ * takes that path for the user's edit, never for a throw-away. Merges list nothing of their own.
1233
+ */
1234
+ const committedAmong = async (call, g, since, head, paths) => {
1235
+ if (since === head || head === null || paths.length === 0) return new Set();
1236
+ const range = since === null ? [head] : [head, `^${since}`];
1237
+ const key = range.join(' ');
1238
+ if (!call.commits.has(key)) {
1239
+ const names = await g.output(call.top, ['--literal-pathspecs', 'log', '--format=', '--name-only', '-z', '--no-renames', '--no-show-signature', ...range, '--'], {
1240
+ maxOutputBytes: LIST_MAX_OUTPUT_BYTES,
1241
+ });
1242
+ // git 2.54 separates nothing but the names; a git that put a line break before a commit's names
1243
+ // would glue it to the first. Both spellings count, so a name that begins with a line break is
1244
+ // found as it is, and an extra spelling can only take a path for the user's edit.
1245
+ const changed = new Set();
1246
+ for (const name of names.split('\0')) {
1247
+ if (name !== '') changed.add(name).add(name.replace(/^\n+/, ''));
1248
+ }
1249
+ call.commits.set(key, changed);
1250
+ }
1251
+ const changed = call.commits.get(key);
1252
+ return new Set(paths.filter((file) => changed.has(file)));
1253
+ };
1254
+
1255
+ // How many paths each look takes where the first path found answers the question: one, then sixteen
1256
+ // times more each time. A work tree that still holds the change answers at its first path.
1257
+ const LOOK_GROWTH = 16;
1258
+ const inLooks = (list) => {
1259
+ const looks = [];
1260
+ for (let at = 0, size = 1; at < list.length; at += size, size *= LOOK_GROWTH) looks.push(list.slice(at, at + size));
1261
+ return looks;
1262
+ };
1263
+
1264
+ /**
1265
+ * What the user did to each path of the change from `before` to `applied`, the maintainer's rule of
1266
+ * 2026-09-25 made exact in round eight, per path:
1267
+ * - the working tree holds the applied side: `kept`;
1268
+ * - it holds neither side: `edited`, the user's own edit;
1269
+ * - it holds the side before, and a commit of the user's since the last apply changed that path, one
1270
+ * reachable from this worktree's HEAD and not from `applied-head`: `edited` as well, the user's
1271
+ * committed change, a revert among them, see `committedAmong`;
1272
+ * - it holds the side before, and no commit since touched it: `thrown`.
1273
+ * With `untilTrace` it stops at the first path that is not thrown, a few paths first and more each
1274
+ * time, and `trace` says whether there was one. `trace` is true as well when no path could be told
1275
+ * apart at all: that is no evidence that anything was thrown away.
1276
+ */
1277
+ const whatBecameOf = async (call, g, before, applied, { appliedHead, head, untilTrace = false }) => {
1278
+ const entries = await comparedPaths(call, g, before, applied);
1279
+ const kept = [];
1280
+ const edited = [];
1281
+ const thrown = [];
1282
+ for (const look of untilTrace ? inLooks(entries) : [entries]) {
1283
+ const held = await sidesHeld(call, g, look);
1284
+ const committed = await committedAmong(call, g, appliedHead, head, look.filter((_, index) => held[index] === 'from').map((entry) => entry.path));
1285
+ look.forEach((entry, index) => {
1286
+ if (held[index] === 'to') kept.push(entry.path);
1287
+ else if (held[index] === 'neither' || committed.has(entry.path)) edited.push(entry.path);
1288
+ else thrown.push(entry.path);
1289
+ });
1290
+ if (untilTrace && kept.length + edited.length > 0) break;
1291
+ }
1292
+ return { kept, edited, thrown, trace: kept.length + edited.length > 0 || thrown.length === 0 };
1293
+ };
1294
+
1295
+ /**
1296
+ * What an interrupted apply left in the working tree, for the paths of the patch from `from` to `to`:
1297
+ * `finished`, `not_started` or `partly`. First each path is read, see `sidesHeld`: every path as in
1298
+ * `to` is `finished`, every path as in `from` is `not_started`. The user may have edited something
1299
+ * since, which leaves a path as neither, so the dry run an apply makes decides then: when no path holds
1300
+ * `to` and the patch still applies, nothing was written; when no path holds `from` and the patch
1301
+ * applies backwards, all of it was. `git apply` writes a whole file at a time, so a path it wrote holds
1302
+ * `to`, and the first condition keeps a hunk that would apply a second time, as a line added among
1303
+ * repeated lines can, from being taken for one never written. Anything else is `partly`. Nothing of the
1304
+ * user's is written.
1305
+ */
1306
+ const interruptedApplyState = async (call, g, from, to) => {
1307
+ const held = await sidesHeld(call, g, await comparedPaths(call, g, from, to));
1308
+ if (held.every((side) => side === 'to')) return 'finished';
1309
+ if (held.every((side) => side === 'from')) return 'not_started';
1310
+ if (!held.includes('to') && (await checkOf(call, g, from, to)).code === 0) return 'not_started';
1311
+ if (!held.includes('from') && (await dryRun(g, call.top, (await patchOf(call, g, from, to)).file, { reverse: true })).code === 0) return 'finished';
1312
+ return 'partly';
1313
+ };
1314
+
1315
+ /**
1316
+ * What an interrupted apply to `applying` left, read against `base`, what its patch was built from,
1317
+ * and, when that reads as `partly`, against each of `others` in turn, where the applies before it
1318
+ * came from: the user may have thrown an earlier apply away after the host went away, and a reviewer
1319
+ * had an attempt that wrote nothing taken for a part and the route closed. Resolves `{ state, base }`,
1320
+ * `base` being what the working tree matched.
1321
+ */
1322
+ const readInterrupted = async (call, g, applying, base, others) => {
1323
+ const state = await interruptedApplyState(call, g, base, applying);
1324
+ if (state !== 'partly') return { state, base };
1325
+ for (const other of new Set(others.filter((candidate) => candidate !== null && candidate !== base))) {
1326
+ const again = await interruptedApplyState(call, g, other, applying);
1327
+ if (again !== 'partly') return { state: again, base: other };
1328
+ }
1329
+ return { state, base };
1330
+ };
1331
+
1332
+ /**
1333
+ * Where the next apply builds its patch from, the maintainer's decision of 2026-09-25 made exact:
1334
+ * "thrown away" is decided path by path from what is authoritative, the refs of the last apply, the
1335
+ * user's HEAD and the working tree, see `whatBecameOf`, and a part thrown away is never passed over in
1336
+ * silence. An edit of the user's, committed or not, is never a throw-away.
1337
+ * - No apply yet: from the start. A last apply recorded before `applied-from` existed: from it.
1338
+ * - Every path of every apply so far thrown away: the whole work from the start.
1339
+ * - Every path of the last apply thrown away: from where that apply started, even where the patch
1340
+ * from it would fit.
1341
+ * - Some thrown away and some kept or edited: `{ partly: { gone, stillThere } }`, which the apply
1342
+ * refuses, because any patch would leave what the user threw away out without a word.
1343
+ * - Nothing thrown away: from the last apply.
1344
+ * Resolves `{ from }` or `{ partly }`. `g` reads the working tree back within the call's time.
1345
+ */
1346
+ const decideBase = async (call, g, { start, applied, appliedFrom, appliedHead, head }) => {
1347
+ if (applied === null) return { from: start };
1348
+ if (appliedFrom === null) return { from: applied };
1349
+ if (appliedFrom !== start && !(await whatBecameOf(call, g, start, applied, { appliedHead, head, untilTrace: true })).trace) return { from: start };
1350
+ const last = await whatBecameOf(call, g, appliedFrom, applied, { appliedHead, head });
1351
+ const stillThere = [...last.kept, ...last.edited];
1352
+ if (last.thrown.length > 0 && stillThere.length > 0) return { partly: { gone: last.thrown, stillThere } };
1353
+ if (last.thrown.length > 0) return { from: appliedFrom };
1354
+ return { from: applied };
1355
+ };
1356
+
1357
+ /**
1358
+ * The files among the paths of the patch from `from` to the result that a filter such as Git LFS
1359
+ * keeps in the user's project, and that the patch cannot fit for that reason alone: the working file
1360
+ * is byte for byte the blob `from` has, which is what that filter's smudge left there for a file the
1361
+ * agent changed, while git reads it back through the filter as something else, so `git apply` finds
1362
+ * another file than the patch expects. Asked only after a dry run failed, to say so in the refusal,
1363
+ * and cheaply: the bytes first, then the filter attribute, and the filter itself only for the files
1364
+ * whose bytes are the blob and whose attributes name a configured filter.
1365
+ */
1366
+ const filteredInTheWay = async (call, g, from, result) => {
1367
+ const entries = (await comparedPaths(call, g, from, result)).filter((entry) => isRegular(entry.from));
1368
+ await lookAtAll(call, entries);
1369
+ const bytesAsBefore = entries.filter((entry) => holdsRaw(call, call.readings.get(entry.path), entry.from));
1370
+ if (bytesAsBefore.length === 0) return [];
1371
+ const env = { GIT_INDEX_FILE: path.join(call.directory, 'no-index') };
1372
+ const drivers = new Set((await g.run(call.top, ['config', '-z', '--get-regexp', '^filter\\..*\\.(clean|process)$'])).stdout.split('\0').filter(Boolean)
1373
+ .map((entry) => entry.slice(0, entry.indexOf('\n')).replace(/^filter\./, '').replace(/\.(?:clean|process)$/, '')));
1374
+ const fields = (await g.output(call.top, ['check-attr', '-z', '--stdin', 'filter'], { env, stdin: bytesAsBefore.map((entry) => `${entry.path}\0`).join('') })).split('\0');
1375
+ const covered = new Set();
1376
+ for (let index = 0; index + 2 < fields.length; index += 3) {
1377
+ if (drivers.has(fields[index + 2])) covered.add(fields[index]);
1378
+ }
1379
+ const asked = bytesAsBefore.filter((entry) => covered.has(entry.path));
1380
+ await cleanIds(call, g, asked.map((entry) => entry.path));
1381
+ return asked.filter((entry) => call.readings.get(entry.path).clean !== entry.from.id).map((entry) => entry.path);
1382
+ };
1383
+
1384
+ /**
1385
+ * Whether any of `paths`, already read back in this call, holds something else than `head` has
1386
+ * there, as `git status` compares: those are what a merge of the branch, or a switch to it, would
1387
+ * refuse to overwrite. A file the user committed as it is, or a deletion they committed, is not.
1388
+ * Asked only to word the refusal of a part thrown away, so a failure is the caller's to answer.
1389
+ */
1390
+ const differFromHead = async (call, g, head, paths) => {
1391
+ const sides = new Map();
1392
+ if (head !== null) {
1393
+ const tree = await g.output(call.top, ['ls-tree', '-r', '-z', '--full-tree', head], { maxOutputBytes: LIST_MAX_OUTPUT_BYTES });
1394
+ for (const entry of tree.split('\0')) {
1395
+ const tab = entry.indexOf('\t');
1396
+ if (tab === -1) continue;
1397
+ const [mode, , id] = entry.slice(0, tab).split(' ');
1398
+ sides.set(entry.slice(tab + 1), { mode, id });
1399
+ }
1400
+ }
1401
+ const entries = paths.map((file) => ({ path: file, head: sides.get(file) ?? { mode: ABSENT, id: '' } }));
1402
+ const reading = (entry) => call.readings.get(entry.path);
1403
+ await cleanIds(call, g, entries.filter((entry) => isRegular(entry.head) && !holdsRaw(call, reading(entry), entry.head)).map((entry) => entry.path));
1404
+ return entries.some((entry) => !holdsRaw(call, reading(entry), entry.head) && !holdsClean(call, reading(entry), entry.head));
1405
+ };
1406
+
1407
+ /**
1408
+ * Where this space stands for an apply as uncommitted changes, read and nothing else: whether that
1409
+ * route is `open` or `closed`, the result the last apply wrote (`lastApplied`, or null), the result
1410
+ * that is there now (`result`, or null before any bring-out), how many paths an apply would write
1411
+ * now (`newPaths`), and whether an earlier apply was interrupted (`interruptedApply`), which the
1412
+ * next apply sorts out before anything else. `newPaths` counts from where the next apply builds its
1413
+ * patch, decided by `readInterrupted` and `decideBase` as the apply decides it: from what the last
1414
+ * apply wrote, from what an interrupted apply wrote when the working tree shows it finished, from
1415
+ * before the last apply or from the start when the user threw applies away, and from the last apply
1416
+ * when the user threw away part of it, which the apply then refuses.
1417
+ * `newPaths` is null without a result, and also when its list is longer than the host reads,
1418
+ * `newPathsOverLimit`: an apply of that many is refused as too large, and the branch is the way. It
1419
+ * is null as well when reading the working tree back did not end in time, `newPathsUndecided`, which
1420
+ * the apply answers with `changes_undecided`. An intent beside a closed route is not reported:
1421
+ * nothing applies as changes there any more. Waits for an apply of the same space in this process to
1422
+ * end, so it never reports an apply under way as interrupted.
1423
+ */
1424
+ const describeApplyState = (request) => withHostGit('code_out_failed', async (g, directory) => {
1425
+ const { repository, spaceId } = request ?? {};
1426
+ requireSpaceId(spaceId);
1427
+ await requireGitVersion(g, directory);
1428
+ const top = await requireWorkTree(g, repository);
1429
+ return inTurn(await applyTurnKey(g, top, spaceId), async () => {
1430
+ const call = startCall(g, directory, top);
1431
+ const back = readingBack(call);
1432
+ const start = await requireStart(g, top, spaceId);
1433
+ const result = await readRef(g, top, resultRef(spaceId));
1434
+ const lastApplied = await readRef(g, top, appliedRef(spaceId));
1435
+ const closed = await readRef(g, top, closedRef(spaceId));
1436
+ const applying = closed ? null : await readRef(g, top, applyingRef(spaceId));
1437
+ // The last apply, where it came from and HEAD then, as the next apply will see them once it has
1438
+ // sorted out an interrupted one.
1439
+ let applied = lastApplied;
1440
+ let appliedFrom = applied ? await readRef(g, top, appliedFromRef(spaceId)) : null;
1441
+ let appliedHead = applied ? await readRef(g, top, appliedHeadRef(spaceId)) : null;
1442
+ let from = applied ?? start;
1443
+ let undecidedHere = false;
1444
+ if (result) {
1445
+ try {
1446
+ let interrupted = null;
1447
+ if (applying) {
1448
+ const recordedBase = await readRef(g, top, applyingFromRef(spaceId));
1449
+ const read = await readInterrupted(call, back, applying, recordedBase ?? lastApplied ?? start, [appliedFrom, start]);
1450
+ interrupted = read.state;
1451
+ if (interrupted === 'finished') {
1452
+ applied = applying;
1453
+ appliedFrom = read.base;
1454
+ appliedHead = await readRef(g, top, applyingHeadRef(spaceId));
1455
+ }
1456
+ }
1457
+ from = applied ?? start;
1458
+ // Where the apply would close the route first, it never gets to choose a base, so neither does this.
1459
+ if (!closed && interrupted !== 'partly') {
1460
+ const decision = await decideBase(call, back, { start, applied, appliedFrom, appliedHead, head: await readRef(g, top, 'HEAD') });
1461
+ from = decision.from ?? applied;
1462
+ }
1463
+ } catch (error) {
1464
+ if (error.code !== 'changes_undecided') throw error;
1465
+ undecidedHere = true;
1466
+ }
1467
+ }
1468
+ let newPaths = null;
1469
+ if (result && !undecidedHere) {
1470
+ try {
1471
+ const names = await g.output(top, ['diff-tree', '-r', '-z', '--name-only', '--no-renames', from, result], { maxOutputBytes: LIST_MAX_OUTPUT_BYTES, timeoutMs: APPLY_TIMEOUT_MS });
1472
+ newPaths = names.split('\0').filter(Boolean).length;
1473
+ } catch (error) {
1474
+ if (error.code !== 'command_output_too_large') throw error;
1475
+ }
1476
+ }
1477
+ return {
1478
+ changesRoute: closed ? 'closed' : 'open',
1479
+ result,
1480
+ lastApplied,
1481
+ newPaths,
1482
+ newPathsOverLimit: result !== null && newPaths === null && !undecidedHere,
1483
+ newPathsUndecided: undecidedHere,
1484
+ interruptedApply: applying !== null,
1485
+ };
1486
+ });
1487
+ });
1488
+
1489
+ /**
1490
+ * Applies what the space changed to the working tree as uncommitted changes. The patch is built on
1491
+ * the host into a file and applied at the top level of the work tree after a dry run. The index is
1492
+ * not touched.
1493
+ *
1494
+ * It starts from what the last apply of this space wrote, `refs/openchamber/spaces/<id>/applied`,
1495
+ * and from the space's start when there was none. So a second apply, after the agent worked on and
1496
+ * the work came out again, brings only what is new since the first one, and applying the same result
1497
+ * twice has nothing to apply. That ref is written only once the apply has gone through; when writing
1498
+ * it fails afterwards the changes are in the working tree all the same, and `remembered` is false.
1499
+ *
1500
+ * When the user threw applies away, the whole work comes again instead, from before them, and a part
1501
+ * thrown away is refused: see `decideBase`. Each apply records what its patch was built from,
1502
+ * `applied-from`, and the user's HEAD at the time, `applied-head`, beside `applied`.
1503
+ *
1504
+ * A dry run that fails is `changes_do_not_apply` and nothing was touched. A real apply that fails
1505
+ * after a clean dry run, for a folder that cannot be written or a disk that is full, is
1506
+ * `changes_partly_applied`: part of the work may be in the working tree, and the remembered ref is
1507
+ * not moved, because it no longer describes what is there. Reading the working tree back, or the dry
1508
+ * run, that does not end in time is `changes_undecided`.
1509
+ *
1510
+ * Each of those closes this route for the space, as `refs/openchamber/spaces/<id>/changes-closed`:
1511
+ * the user's project and the agent's work have gone apart, or cannot be compared, and every later call
1512
+ * refuses at once with `changes_route_closed`, before any patch is built. `applyAsBranch` keeps
1513
+ * working and closes nothing. Nothing in this stage opens the route again.
1514
+ *
1515
+ * Resolves `{ status: 'applied', appliedPaths, remembered, nestedRepositories, conflicted }`, or
1516
+ * `{ status: 'nothing_to_apply' }`. `appliedPaths` counts the paths this call wrote, which is not
1517
+ * `changedPaths` of `bringCodeOut`: that one counts against the space's start.
1518
+ */
1519
+ const applyAsChanges = (request) => withHostGit('code_out_failed', async (g, directory) => {
1520
+ const { repository, spaceId, maxChangedBytes = MAX_CHANGED_BYTES, maxChangedEntries = MAX_CHANGED_ENTRIES } = request ?? {};
1521
+ requireSpaceId(spaceId);
1522
+ requireLimit(maxChangedBytes, 'maxChangedBytes');
1523
+ requireLimit(maxChangedEntries, 'maxChangedEntries');
1524
+ await requireGitVersion(g, directory);
1525
+ const top = await requireWorkTree(g, repository);
1526
+ return inTurn(await applyTurnKey(g, top, spaceId), () => applyInTurn(startCall(g, directory, top), spaceId, { maxChangedBytes, maxChangedEntries }));
1527
+ });
1528
+
1529
+ /**
1530
+ * The patch from `from` to the result, with every check an apply makes before it writes anything,
1531
+ * and nothing of the user's written: the caps, the names, the folder walk, the patch in the call's
1532
+ * folder, and the dry run. Throws the refusals that leave the route open, `changes_too_large`,
1533
+ * `name_not_allowed_here`, `case_only_rename` and `patch_not_possible`. Resolves `{ outcome:
1534
+ * 'nothing' }` when the two are the same, `{ outcome: 'blocked', folder }` for a folder on the way
1535
+ * that leads somewhere else, `{ outcome: 'slow' }` for a dry run that did not end in time, and
1536
+ * otherwise `{ outcome: 'checked', change, patch, check }`, `check` being the dry run. What closes the
1537
+ * route is the caller's to write.
1538
+ */
1539
+ const preparePatch = async (call, from, result, { maxChangedBytes, maxChangedEntries }) => {
1540
+ const { g, top } = call;
1541
+ // What this patch would read and write, measured here because this is where it is paid, and for
1542
+ // a pair `bringCodeOut` never saw: the result against the last apply.
1543
+ // The folders count against a cap of the same size as the paths: a folder costs an apply about what
1544
+ // a path costs in memory, one node of the name check, one entry of the link check. A cap of its own
1545
+ // and not a share of the paths' cap, so a large ordinary change, twenty thousand files in sixty
1546
+ // thousand folders of their own, still applies; together at most twice what the paths' cap alone
1547
+ // allowed. The steps get FOLDER_STEPS_PER_ENTRY times that, see there.
1548
+ const tooLarge = (what, limit) => new SpaceError(
1549
+ 'changes_too_large',
1550
+ `The work of the space is too large to apply as uncommitted changes: it ${what}. Nothing was changed. Apply it as a branch instead.`,
1551
+ { limit },
1552
+ );
1553
+ const refuse = {
1554
+ tooMany: (what, limit) => tooLarge(what, limit),
1555
+ tooLarge: (bytes, limit) => tooLarge(`would read and write ${bytes} bytes, more than the limit of ${limit}`, limit),
1556
+ };
1557
+ const change = await measureChange(g, top, from, result, {
1558
+ maxChangedBytes, maxChangedEntries, maxFolders: maxChangedEntries, maxFolderSteps: maxChangedEntries * FOLDER_STEPS_PER_ENTRY,
1559
+ }, refuse, () => listed(call, g, from, result));
1560
+ if (change.changedPaths === 0) return { outcome: 'nothing' };
1561
+ // A name the agent made that this computer cannot hold. Not a sign that the two went apart: after
1562
+ // the agent renames it, the next bring-out applies as usual, so the route stays open.
1563
+ const names = {
1564
+ ignoreCase: await readSetting(g, top, 'core.ignorecase'),
1565
+ precompose: await readSetting(g, top, 'core.precomposeunicode'),
1566
+ };
1567
+ if (change.created.length > 0) {
1568
+ const all = names.ignoreCase || names.precompose
1569
+ ? (await g.output(top, ['ls-tree', '-r', '-z', '--name-only', result], { maxOutputBytes: LIST_MAX_OUTPUT_BYTES })).split('\0').filter(Boolean)
1570
+ : [];
1571
+ const unholdable = nameNotAllowedHere(change.created, all, {
1572
+ platform, ...names, deleted: change.deleted, top, longPaths: await readSetting(g, top, 'core.longpaths'),
1573
+ });
1574
+ if (unholdable !== null) {
1575
+ const [shown, other] = reported([unholdable.path, unholdable.other ?? '']).paths;
1576
+ const details = { path: shown, rule: unholdable.rule, other: other ?? null };
1577
+ if (unholdable.rule === 'case_only_rename') {
1578
+ throw new SpaceError(
1579
+ 'case_only_rename',
1580
+ `The agent renamed ${other} to ${shown}, changing only the case of the name, and on this computer that cannot be applied as uncommitted changes, so nothing was changed. The branch holds it: apply the work as a branch, or have the agent choose a new name and bring the work out again.`,
1581
+ details,
1582
+ );
1583
+ }
1584
+ const why = {
1585
+ differs_only_in_case: `differs from ${other} only in case, and this computer takes the two for one file`,
1586
+ path_too_long: 'is a path too long for this computer, in your project\'s folder',
1587
+ }[unholdable.rule] ?? 'has a name this computer cannot hold';
1588
+ throw new SpaceError(
1589
+ 'name_not_allowed_here',
1590
+ `The agent made ${shown}, which ${why}, so nothing was changed. Have the agent rename it, then bring the work out again.`,
1591
+ details,
1592
+ );
1593
+ }
1594
+ }
1595
+ // Checked here and not left to git, before anything is built or written.
1596
+ const redirected = await firstRedirectedFolder(top, change.paths, { ...names, deletedLinks: change.deletedLinks });
1597
+ if (redirected !== null) return { outcome: 'blocked', folder: redirected };
1598
+ let patch;
1599
+ try {
1600
+ patch = await patchOf(call, g, from, result);
1601
+ } catch (error) {
1602
+ // git refuses to build a diff it cannot hold, for one huge file among others.
1603
+ throw new SpaceError(
1604
+ 'patch_not_possible',
1605
+ `A patch of the work of the space could not be built, so nothing was changed. Apply it as a branch instead. Git said: ${error.message}`,
1606
+ { cause: error.code ?? null },
1607
+ );
1608
+ }
1609
+ try {
1610
+ return { outcome: 'checked', change, patch: patch.file, check: await checkOf(call, g, from, result) };
1611
+ } catch (error) {
1612
+ // The dry run reads every file the patch touches, through the user's filters, which can be slow
1613
+ // enough never to end in time; every later call would then fail the same way.
1614
+ if (error.code === 'command_timeout') return { outcome: 'slow' };
1615
+ throw error;
1616
+ }
1617
+ };
1618
+
1619
+ /** Up to three of the paths `shown`, `{ count, paths }`, as words: "a, b and 4 more". */
1620
+ const someOf = ({ count, paths }) => {
1621
+ const named = paths.slice(0, 3);
1622
+ if (count > named.length) return `${named.join(', ')} and ${count - named.length} more`;
1623
+ return named.length === 1 ? named[0] : `${named.slice(0, -1).join(', ')} and ${named.at(-1)}`;
1624
+ };
1625
+
1626
+ /** The body of `applyAsChanges`, run while no other apply of this space in this process runs. */
1627
+ const applyInTurn = async (call, spaceId, caps) => {
1628
+ const { g, top } = call;
1629
+ const start = await requireStart(g, top, spaceId);
1630
+ const result = await requireResult(g, top, spaceId);
1631
+ if (await readRef(g, top, closedRef(spaceId))) {
1632
+ throw new SpaceError(
1633
+ 'changes_route_closed',
1634
+ `The work of this space is no longer applied as uncommitted changes: it and your project went apart. ${BRANCH_FROM_NOW_ON}`,
1635
+ );
1636
+ }
1637
+ // Written before a refusal below leaves, so a later call refuses at once.
1638
+ const closing = `openchamber: the work of space ${spaceId} is applied as a branch now`;
1639
+ const close = () => g.run(top, ['update-ref', '-m', closing, closedRef(spaceId), result]);
1640
+ // An intent is up to three refs, `applying` at `at`, `applying-from` at `base` and `applying-head`
1641
+ // at `head`, the last two absent in an intent written before they existed, or with no commit at
1642
+ // HEAD. Where one was written, the route closes and the intent goes in one transaction: a host that
1643
+ // dies in between leaves both or neither, never a closed route beside an intent. When the
1644
+ // transaction fails, neither was written, and the next call finds the intent and sorts it out.
1645
+ const forgetIntent = (at, base, head) => [
1646
+ `delete ${applyingRef(spaceId)} ${at}\n`,
1647
+ base ? `delete ${applyingFromRef(spaceId)} ${base}\n` : '',
1648
+ head ? `delete ${applyingHeadRef(spaceId)} ${head}\n` : '',
1649
+ ].join('');
1650
+ const closeAndForget = async (at, base, head) => (await g.run(top, ['update-ref', '-m', closing, '--stdin'], {
1651
+ stdin: `update ${closedRef(spaceId)} ${at}\n${forgetIntent(at, base, head)}`,
1652
+ })).code === 0;
1653
+ // Where the last apply leaves the user's HEAD, moving with `applied`.
1654
+ const headRecord = (head) => (head ? `update ${appliedHeadRef(spaceId)} ${head}\n` : `delete ${appliedHeadRef(spaceId)}\n`);
1655
+ // What the last apply of this space put there, or null when there was none, what its patch was
1656
+ // built from, or null in a repository from before that was recorded, and the user's HEAD then.
1657
+ let applied = await readRef(g, top, appliedRef(spaceId));
1658
+ let appliedFrom = applied ? await readRef(g, top, appliedFromRef(spaceId)) : null;
1659
+ let appliedHead = applied ? await readRef(g, top, appliedHeadRef(spaceId)) : null;
1660
+ const head = await readRef(g, top, 'HEAD');
1661
+ const back = readingBack(call);
1662
+ // An earlier apply was interrupted: the host went away between writing the files and the record.
1663
+ let intent = null;
1664
+ const applying = await readRef(g, top, applyingRef(spaceId));
1665
+ if (applying) intent = { at: applying, base: await readRef(g, top, applyingFromRef(spaceId)), head: await readRef(g, top, applyingHeadRef(spaceId)) };
1666
+ let decision;
1667
+ try {
1668
+ if (intent) {
1669
+ // An intent from before `applying-from` was built from the last apply, which has not moved since.
1670
+ const { state, base } = await readInterrupted(call, back, intent.at, intent.base ?? applied ?? start, [appliedFrom, start]);
1671
+ if (state === 'partly') {
1672
+ const recorded = await closeAndForget(intent.at, intent.base, intent.head);
1673
+ throw new SpaceError(
1674
+ 'changes_partly_applied',
1675
+ `An earlier apply of the work of this space was interrupted, and part of it is in your project: look at what changed. Your project is now in a state this cannot reason about. ${BRANCH_FROM_NOW_ON}`,
1676
+ { interrupted: true, recorded },
1677
+ );
1678
+ }
1679
+ const record = state === 'finished'
1680
+ ? `update ${appliedRef(spaceId)} ${intent.at}\nupdate ${appliedFromRef(spaceId)} ${base}\n${headRecord(intent.head)}${forgetIntent(intent.at, intent.base, intent.head)}`
1681
+ : forgetIntent(intent.at, intent.base, intent.head);
1682
+ await g.output(top, ['update-ref', '-m', `openchamber: sorted out an interrupted apply of space ${spaceId}`, '--stdin'], { stdin: record });
1683
+ if (state === 'finished') {
1684
+ applied = intent.at;
1685
+ appliedFrom = base;
1686
+ appliedHead = intent.head;
1687
+ }
1688
+ intent = null;
1689
+ }
1690
+ decision = await decideBase(call, back, { start, applied, appliedFrom, appliedHead, head });
1691
+ } catch (error) {
1692
+ if (error.code !== 'changes_undecided') throw error;
1693
+ const recorded = intent ? await closeAndForget(intent.at, intent.base, intent.head) : (await close()).code === 0;
1694
+ throw new SpaceError('changes_undecided', `${error.message} So nothing was changed. ${BRANCH_FROM_NOW_ON}`, { ...error.details, recorded });
1695
+ }
1696
+ if (decision.partly) {
1697
+ await close();
1698
+ const gone = reported(decision.partly.gone);
1699
+ const kept = reported(decision.partly.stillThere);
1700
+ // "Changed since", not "by you": a pull of a colleague's change into one of those files lands here
1701
+ // too. The advice to clear the way for the branch only where git would refuse it: a file the user
1702
+ // committed as it is does not stand in the way. Asked after the route is closed; when it cannot be
1703
+ // told, the advice stays.
1704
+ const inTheWay = await differFromHead(call, back, head, decision.partly.stillThere).catch(() => true);
1705
+ throw new SpaceError(
1706
+ 'changes_do_not_apply',
1707
+ `You threw away part of what was last applied from this space: ${someOf(gone)} ${gone.count === 1 ? 'is' : 'are'} back as before, while ${someOf(kept)} ${kept.count === 1 ? 'is' : 'are'} still there or changed since. An apply now would leave out what you threw away without a word, so nothing was changed. ${BRANCH_FROM_NOW_ON}${inTheWay ? ' Before you merge that branch or switch to it, remove or commit the files of this space that are still in your project, or git will refuse.' : ''}`,
1708
+ { thrownAway: gone, stillThere: kept },
1709
+ );
1710
+ }
1711
+ const { from } = decision;
1712
+ const prepared = await preparePatch(call, from, result, caps);
1713
+ if (prepared.outcome === 'nothing') return { status: 'nothing_to_apply' };
1714
+ if (prepared.outcome === 'blocked') {
1715
+ await close();
1716
+ const shown = reported([prepared.folder]).paths[0];
1717
+ throw new SpaceError(
1718
+ 'changes_blocked_by_link',
1719
+ `The work of the space writes into ${shown}, which in your project is a link to another place, so nothing was changed. ${BRANCH_FROM_NOW_ON}`,
1720
+ { path: shown },
1721
+ );
1722
+ }
1723
+ if (prepared.outcome === 'slow') {
1724
+ await close();
1725
+ throw new SpaceError(
1726
+ 'changes_undecided',
1727
+ `Checking whether the work of the space fits your project took longer than ${APPLY_TIMEOUT_MS / 1000} seconds, so nothing was changed. ${BRANCH_FROM_NOW_ON}`,
1728
+ { limitMs: APPLY_TIMEOUT_MS },
1729
+ );
1730
+ }
1731
+ const { change, patch, check } = prepared;
1732
+ if (check.code !== 0) {
1733
+ await close();
1734
+ // A file the user keeps out of git, by a global ignore file or info/exclude, that the space also
1735
+ // made: it was there before the space, so "your project changed" would not be true. Only a hint,
1736
+ // asked after the route is closed, so its failure changes nothing of the answer.
1737
+ const inTheWay = [];
1738
+ for (const file of change.created) {
1739
+ if (await exists(path.join(top, ...file.split('/')))) inTheWay.push(file);
1740
+ }
1741
+ const asked = inTheWay.length === 0 ? null : await g.run(top, ['check-ignore', '-z', '--stdin'], {
1742
+ stdin: inTheWay.map((file) => `${file}\0`).join(''), maxOutputBytes: LIST_MAX_OUTPUT_BYTES,
1743
+ }).catch(() => null);
1744
+ const ignored = asked === null ? [] : asked.stdout.split('\0').filter(Boolean);
1745
+ const said = `Git said: ${tail(check.stderr) || `exit code ${check.code}`}`;
1746
+ // A file an LFS-like filter keeps, which the agent changed and an earlier apply wrote: git reads it
1747
+ // back through the filter as another file than the patch expects. Also only a hint, like the above.
1748
+ const filtered = ignored.length > 0 ? [] : await filteredInTheWay(call, readingBack(call), from, result).catch(() => []);
1749
+ if (filtered.length > 0) {
1750
+ const shown = reported(filtered);
1751
+ throw new SpaceError(
1752
+ 'changes_do_not_apply',
1753
+ `The work of the space changes ${someOf(shown)} again, which a filter such as Git LFS handles in your project. Files like that cannot be updated as uncommitted changes a second time, so nothing was changed. ${BRANCH_FROM_NOW_ON}`,
1754
+ { exitCode: check.code, filteredInTheWay: shown },
1755
+ );
1756
+ }
1757
+ if (ignored.length > 0) {
1758
+ throw new SpaceError(
1759
+ 'changes_do_not_apply',
1760
+ `Your project already has ${reported(ignored).paths[0]}, which git ignores here, and the work of the space adds a file of the same name, so nothing was changed. ${BRANCH_FROM_NOW_ON} ${said}`,
1761
+ { exitCode: check.code, ignoredInTheWay: reported(ignored) },
1762
+ );
1763
+ }
1764
+ // The patch goes from `from`: the start, or an apply that is still in the working tree.
1765
+ const since = from === start ? 'since the space was made' : 'since its work was last applied here';
1766
+ throw new SpaceError(
1767
+ 'changes_do_not_apply',
1768
+ `The work of the space does not fit your project any more: your project changed ${since}, so nothing was changed now. ${BRANCH_FROM_NOW_ON} ${said}`,
1769
+ { exitCode: check.code },
1770
+ );
1771
+ }
1772
+ // The intent, what its patch was built from and HEAD now, before anything is written: a later call
1773
+ // that finds them knows an apply was under way, from where, and whether the user moved on since.
1774
+ await g.output(top, ['update-ref', '-m', `openchamber: applying the work of space ${spaceId}`, '--stdin'], {
1775
+ stdin: `update ${applyingRef(spaceId)} ${result}\nupdate ${applyingFromRef(spaceId)} ${from}\n${head ? `update ${applyingHeadRef(spaceId)} ${head}\n` : ''}`,
1776
+ });
1777
+ // With its tree on a timeout, as the dry run: the user's filters run as children of git here too. Not
1778
+ // at the server's exit, `keepAtExit`: an apply killed in the middle leaves a part in the project and
1779
+ // closes the route, where one left to run finishes and the next call records it.
1780
+ const done = await g.run(top, [...APPLY_ARGS, patch], {
1781
+ timeoutMs: APPLY_TIMEOUT_MS, maxOutputBytes: APPLY_OUTPUT_TAIL_BYTES, keepTail: true, killTree: true, keepAtExit: true,
1782
+ });
1783
+ if (done.code !== 0) {
1784
+ // The dry run passed and this did not, so some of the changes may be in the working tree. The
1785
+ // remembered ref stays where it was, because it no longer describes what is there.
1786
+ const recorded = await closeAndForget(result, from, head);
1787
+ throw new SpaceError(
1788
+ 'changes_partly_applied',
1789
+ `Applying the work of the space stopped in the middle, so part of it may be in your project already: look at what changed. Your project is now in a state this cannot reason about. ${BRANCH_FROM_NOW_ON} Git said: ${tail(done.stderr) || `exit code ${done.code}`}`,
1790
+ { exitCode: done.code, recorded },
1791
+ );
1792
+ }
1793
+ // Only now, with the changes in the working tree: the next apply starts from here. When this fails,
1794
+ // the intent stays, and the next call finds the work in the working tree and finishes the record.
1795
+ const remembered = await g.run(top, ['update-ref', '-m', `openchamber: applied the work of space ${spaceId}`, '--stdin'], {
1796
+ stdin: `update ${appliedRef(spaceId)} ${result}\nupdate ${appliedFromRef(spaceId)} ${from}\n${headRecord(head)}${forgetIntent(result, from, head)}`,
1797
+ });
1798
+ return {
1799
+ status: 'applied',
1800
+ appliedPaths: change.changedPaths,
1801
+ remembered: remembered.code === 0,
1802
+ nestedRepositories: change.nestedRepositories,
1803
+ conflicted: await conflictedPaths(patch),
1804
+ };
1805
+ };
1806
+
1807
+ /**
1808
+ * The key that one repository and one space share for their turn to apply: the repository's common
1809
+ * git folder, where every worktree of it keeps these refs, as its real path. Two linked worktrees of
1810
+ * one repository share the refs of a space, so an apply from one and an apply from the other take
1811
+ * turns too.
1812
+ */
1813
+ const applyTurnKey = async (g, top, spaceId) => {
1814
+ const common = path.resolve(top, line(await g.output(top, ['rev-parse', '--git-common-dir'])));
1815
+ return `${await fs.realpath(common)}\0${spaceId}`;
1816
+ };
1817
+
1818
+ return { bringCodeOut, applyAsBranch, applyAsChanges, describeApplyState };
1819
+ }