@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.
- package/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-DCAQTLMM.js} +1 -1
- package/dist/assets/DiffView-uzNpHBlV.js +4 -0
- package/dist/assets/FilesView-Bkq0sjNx.js +17 -0
- package/dist/assets/{GitView-DwQwqDvu.js → GitView-BmibZC7n.js} +4 -4
- package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
- package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
- package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-BNT2yUVu.js} +1 -1
- package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-D3Azksju.js} +1 -1
- package/dist/assets/MarkdownImageGallery-BGY6tuiK.js +1 -0
- package/dist/assets/MarkdownRendererImpl-DJmhQ37f.js +12 -0
- package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-D_41H2qC.js} +1 -1
- package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-C0RSqa9W.js} +1 -1
- package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
- package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-Bqc-xuDe.js} +4 -4
- package/dist/assets/PluginPane-DQ0k41Q-.js +3 -0
- package/dist/assets/SettingsView-BAiJU5hJ.js +30 -0
- package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DuC4ecLw.js} +1 -1
- package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
- package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
- package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
- package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-BJicp0RC.js} +2 -2
- package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-CiuyzfSW.js} +1 -1
- package/dist/assets/changesTree-CePHbFcj.js +1 -0
- package/dist/assets/commitAndPush-DTKb6IwT.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kM_zJNMt.js} +2 -2
- package/dist/assets/de-BMnfXm-G.js +10 -0
- package/dist/assets/es-DKEvCCL6.js +16 -0
- package/dist/assets/fr-D9fgE2xE.js +14 -0
- package/dist/assets/index-DUke1nme.css +1 -0
- package/dist/assets/{index-OKDYjcMF.js → index-DWB8bR_g.js} +1 -1
- package/dist/assets/index-Di6zUe5h.js +41 -0
- package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-DOoIsGqs.js} +1 -1
- package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-bt5VHvcd.js} +1 -1
- package/dist/assets/ja-BEXad-KW.js +16 -0
- package/dist/assets/ko-DGrQ0lEe.js +16 -0
- package/dist/assets/{main-CGEvLMd2.js → main-BpLQbZC2.js} +2 -2
- package/dist/assets/main-CI9DtHJv.js +896 -0
- package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
- package/dist/assets/markdownCore-DK2KK5RW.js +7 -0
- package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-CIWaR8wG.js} +2 -2
- package/dist/assets/{mobile-C5KRTNOB.js → mobile-CnWKEYIP.js} +2 -2
- package/dist/assets/patchFileDiff-Cqn0C0az.js +13 -0
- package/dist/assets/pl-BlhkmR5b.js +16 -0
- package/dist/assets/pt-BR-Bs96Ew9I.js +16 -0
- package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-D9t0-9nV.js} +2 -2
- package/dist/assets/renderMobileApp-CW7fVs_s.js +25 -0
- package/dist/assets/runtimeEndpointReset-D0PcCb3K.js +27 -0
- package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
- package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-l-MbaNS4.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-C-kGcOpD.js} +1 -1
- package/dist/assets/tr-CehUK1M9.js +7 -0
- package/dist/assets/uk-Bno5WGNz.js +16 -0
- package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-DDjuwVqi.js} +160 -156
- package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Clg8EO3A.js} +2 -2
- package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
- package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-oKF0MAwd.js} +2 -2
- package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
- package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
- package/dist/assets/{web-components-BEo4-ISu.js → web-components-Bc1WPd4i.js} +1 -1
- package/dist/assets/zh-CN-DnAweIWo.js +16 -0
- package/dist/assets/zh-TW-BbBBK25p.js +16 -0
- package/dist/index.html +5 -5
- package/dist/mini-chat.html +5 -5
- package/dist/mobile.html +5 -5
- package/package.json +3 -3
- package/server/index.js +82 -0
- package/server/lib/agent-tool/DOCUMENTATION.md +132 -47
- package/server/lib/agent-tool/runtime.js +34 -15
- package/server/lib/agent-tool/runtime.test.js +15 -0
- package/server/lib/browser-control/DOCUMENTATION.md +14 -2
- package/server/lib/event-stream/global-hub.js +23 -1
- package/server/lib/event-stream/global-hub.test.js +35 -0
- package/server/lib/event-stream/global-ws-bridge.js +3 -1
- package/server/lib/event-stream/upstream-reader.js +3 -2
- package/server/lib/git/DOCUMENTATION.md +1 -1
- package/server/lib/git/service.js +22 -0
- package/server/lib/git/service.test.js +8 -0
- package/server/lib/guests/DOCUMENTATION.md +4 -3
- package/server/lib/guests/catalog.js +19 -1
- package/server/lib/guests/catalog.test.js +22 -0
- package/server/lib/guests/html-styles.js +5 -3
- package/server/lib/guests/html-styles.test.js +3 -1
- package/server/lib/guests/routes.js +11 -3
- package/server/lib/guests/service.js +5 -0
- package/server/lib/guests/surface.js +55 -9
- package/server/lib/guests/surface.test.js +56 -6
- package/server/lib/notifications/DOCUMENTATION.md +10 -0
- package/server/lib/notifications/emit-route.js +118 -0
- package/server/lib/notifications/emit-route.test.js +112 -0
- package/server/lib/openchamber-control/actions.js +16 -2
- package/server/lib/openchamber-control/service.js +23 -0
- package/server/lib/openchamber-control/service.test.js +31 -0
- package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
- package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
- package/server/lib/openchamber-sessions/routes.js +11 -0
- package/server/lib/openchamber-sessions/routes.test.js +18 -0
- package/server/lib/opencode/bootstrap-runtime.js +14 -1
- package/server/lib/opencode/compatibility.js +6 -3
- package/server/lib/opencode/compatibility.test.js +8 -0
- package/server/lib/opencode/config-entity-v2.test.js +27 -0
- package/server/lib/opencode/config-paths.test.js +12 -0
- package/server/lib/opencode/config-v2.d.ts +2 -0
- package/server/lib/opencode/config-v2.js +42 -0
- package/server/lib/opencode/config-v2.test.js +17 -0
- package/server/lib/opencode/core-routes.js +20 -4
- package/server/lib/opencode/core-routes.test.js +41 -0
- package/server/lib/opencode/env-runtime.js +1 -8
- package/server/lib/opencode/feature-routes-runtime.js +2 -1
- package/server/lib/opencode/lifecycle.js +30 -2
- package/server/lib/opencode/lifecycle.test.js +27 -0
- package/server/lib/opencode/managed-config-file.js +18 -6
- package/server/lib/opencode/managed-config-file.test.js +23 -7
- package/server/lib/opencode/mcp.js +7 -2
- package/server/lib/opencode/npm-registry-config.d.ts +1 -0
- package/server/lib/opencode/npm-registry-config.js +123 -0
- package/server/lib/opencode/npm-registry-config.test.js +167 -0
- package/server/lib/opencode/npm-registry.js +5 -6
- package/server/lib/opencode/npm-registry.test.js +30 -0
- package/server/lib/opencode/openchamber-routes.js +29 -11
- package/server/lib/opencode/openchamber-routes.test.js +18 -0
- package/server/lib/opencode/project-directory-runtime.js +8 -0
- package/server/lib/opencode/project-directory-runtime.test.js +13 -0
- package/server/lib/opencode/provider-source-route.test.js +59 -0
- package/server/lib/opencode/providers.js +3 -0
- package/server/lib/opencode/providers.test.js +62 -0
- package/server/lib/opencode/proxy.js +38 -2
- package/server/lib/opencode/routes-upgrade.test.js +1 -1
- package/server/lib/opencode/routes.js +36 -1
- package/server/lib/opencode/server-utils-runtime.js +5 -0
- package/server/lib/opencode/settings-helpers.js +10 -0
- package/server/lib/opencode/settings-helpers.test.js +2 -2
- package/server/lib/opencode/settings-registry.json +17 -0
- package/server/lib/opencode/settings-runtime.js +1 -0
- package/server/lib/opencode/shared.js +49 -4
- package/server/lib/opencode/shared.test.js +78 -1
- package/server/lib/opencode/shutdown-runtime.js +3 -0
- package/server/lib/opencode/skill-routes.js +28 -12
- package/server/lib/opencode/skill-routes.test.js +121 -1
- package/server/lib/opencode/upgrade-capability.js +0 -14
- package/server/lib/opencode/v1-migration-topup.js +21 -15
- package/server/lib/opencode/v1-migration-topup.test.js +14 -6
- package/server/lib/opencode/v2-install.d.ts +1 -0
- package/server/lib/opencode/v2-install.js +8 -7
- package/server/lib/opencode/v2-install.test.js +13 -4
- package/server/lib/opencode/watcher.js +3 -1
- package/server/lib/opencode/websearch-config.js +13 -1
- package/server/lib/quota/providers/kimi.js +1 -1
- package/server/lib/realtime-proxy.js +5 -1
- package/server/lib/realtime-proxy.test.js +29 -0
- package/server/lib/relay/tunnel-host.js +4 -1
- package/server/lib/relay/tunnel-host.test.js +11 -0
- package/server/lib/session-assist/DOCUMENTATION.md +17 -5
- package/server/lib/session-assist/runtime.js +19 -2
- package/server/lib/session-assist/runtime.test.js +8 -0
- package/server/lib/session-goal/DOCUMENTATION.md +11 -0
- package/server/lib/small-model/DOCUMENTATION.md +11 -10
- package/server/lib/small-model/index.js +27 -32
- package/server/lib/small-model/index.test.js +28 -16
- package/server/lib/spaces/DOCUMENTATION.md +404 -32
- package/server/lib/spaces/code-in-bait.js +145 -6
- package/server/lib/spaces/code-in.js +10 -150
- package/server/lib/spaces/code-in.test.js +7 -83
- package/server/lib/spaces/code-out.js +1819 -0
- package/server/lib/spaces/code-out.test.js +3945 -0
- package/server/lib/spaces/code-transfer.js +173 -0
- package/server/lib/spaces/command-stream.test.js +181 -0
- package/server/lib/spaces/dispatcher.js +670 -0
- package/server/lib/spaces/dispatcher.test.js +582 -0
- package/server/lib/spaces/gatekeeper-channel.js +61 -7
- package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
- package/server/lib/spaces/gatekeeper-program.cjs +68 -14
- package/server/lib/spaces/gatekeeper-program.test.js +118 -3
- package/server/lib/spaces/hardening.js +20 -4
- package/server/lib/spaces/hardening.test.js +18 -3
- package/server/lib/spaces/host-git.js +7 -4
- package/server/lib/spaces/host-git.test.js +6 -2
- package/server/lib/spaces/host.js +310 -0
- package/server/lib/spaces/host.test.js +273 -0
- package/server/lib/spaces/journey.js +552 -0
- package/server/lib/spaces/journey.test.js +589 -0
- package/server/lib/spaces/layout.js +42 -8
- package/server/lib/spaces/manager.js +4 -3
- package/server/lib/spaces/manager.test.js +6 -0
- package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
- package/server/lib/spaces/places/contract-suite.js +36 -0
- package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
- package/server/lib/spaces/places/docker-live-support.js +27 -4
- package/server/lib/spaces/places/docker.js +33 -5
- package/server/lib/spaces/places/docker.test.js +42 -3
- package/server/lib/spaces/places/escape-suite.js +118 -14
- package/server/lib/spaces/places/fake-docker.js +3 -1
- package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
- package/server/lib/spaces/places/memory-place.js +24 -1
- package/server/lib/spaces/places/registry.js +1 -1
- package/server/lib/spaces/places/server.docker.live.test.js +69 -33
- package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
- package/server/lib/spaces/routes.js +216 -0
- package/server/lib/spaces/routes.test.js +213 -0
- package/server/lib/spaces/run-command.js +261 -8
- package/server/lib/spaces/run-command.test.js +193 -3
- package/server/lib/spaces/space-events.js +153 -0
- package/server/lib/spaces/space-events.test.js +160 -0
- package/server/lib/spaces/space-opencode.js +52 -0
- package/server/lib/spaces/space-opencode.test.js +41 -0
- package/server/lib/spaces/space-records.js +150 -0
- package/server/lib/spaces/space-records.test.js +113 -0
- package/server/lib/spaces/space-sessions.js +210 -0
- package/server/lib/spaces/space-sessions.test.js +161 -0
- package/server/lib/spaces/websocket.js +329 -0
- package/server/lib/spaces/websocket.test.js +345 -0
- package/server/lib/ui-auth/ui-auth.js +8 -2
- package/server/lib/ui-auth/ui-auth.test.js +29 -0
- package/server/opencode-proxy.test.js +113 -0
- package/dist/assets/DiffView-D2p4Ar0H.js +0 -4
- package/dist/assets/FilesView-BeTtbsip.js +0 -17
- package/dist/assets/GuestSurfacePane-DIJvkkM-.js +0 -1
- package/dist/assets/MarkdownImageGallery-CY0dWTaE.js +0 -1
- package/dist/assets/MarkdownRendererImpl-ILdXzLrt.js +0 -12
- package/dist/assets/PluginPane-B39JEUL8.js +0 -3
- package/dist/assets/SettingsView-13B2_G-A.js +0 -29
- package/dist/assets/commitAndPush-CcI4ol7c.js +0 -1
- package/dist/assets/de-BtYnUgII.js +0 -10
- package/dist/assets/es-crP5qPye.js +0 -16
- package/dist/assets/fr-CLSRuALk.js +0 -14
- package/dist/assets/index-CLcM8HKx.css +0 -1
- package/dist/assets/index-CvLgnuNI.js +0 -41
- package/dist/assets/ja-SZ1-ucLV.js +0 -16
- package/dist/assets/ko-HthbT_Ve.js +0 -16
- package/dist/assets/main-BJklC7Cl.js +0 -896
- package/dist/assets/markdownCore-BzQuETm0.js +0 -5
- package/dist/assets/patchFileDiff-DGw2KryM.js +0 -13
- package/dist/assets/pl-C8eYfjL1.js +0 -16
- package/dist/assets/pt-BR-cYOrZdxS.js +0 -16
- package/dist/assets/renderMobileApp-CMxjwisj.js +0 -25
- package/dist/assets/runtimeEndpointReset-C45jrFq4.js +0 -27
- package/dist/assets/scheduledTasksApi-CkK1F9s2.js +0 -1
- package/dist/assets/tr-B4IQ5t-T.js +0 -7
- package/dist/assets/uk-DCoXG6ve.js +0 -16
- package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
- package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
- package/dist/assets/zh-CN-BXBoxsFk.js +0 -16
- 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
|
+
}
|