@vantran-se/openchamber-web 2.0.3 → 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/README.md +20 -28
- package/bin/cli.js +3 -1
- package/bin/cli.test.js +13 -4
- package/bin/lib/DOCUMENTATION.md +4 -1
- package/bin/lib/commands-serve.js +9 -0
- package/dist/assets/{CodeMirrorCommentWidgets-CWhdh0wf.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-DKIjpnu4.js → GitView-BmibZC7n.js} +4 -4
- package/dist/assets/{GuestAttachDialog-BIa9jCfL.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
- package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
- package/dist/assets/{JsonTreeView-CtI1Mqwo.js → JsonTreeView-BNT2yUVu.js} +1 -1
- package/dist/assets/{LinearIssuesView-BD2zLu-r.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-B8TxloXZ.js → OnboardingScreen-D_41H2qC.js} +1 -1
- package/dist/assets/{PatchDiff--Civs1xY.js → PatchDiff-C0RSqa9W.js} +1 -1
- package/dist/assets/{PierreDiffViewer-BlDmw3-A.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
- package/dist/assets/{PlanView-DyH2SyCP.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-y9WpZqK6.js → SettingsWindow-DuC4ecLw.js} +1 -1
- package/dist/assets/{SubmoduleDiffSummary-DeFmhPFZ.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
- package/dist/assets/{ToolOutputDialog-8DmF7_PZ.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
- package/dist/assets/{ToolPartDiffPreview-Ca15DKE1.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-BfEBad09.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
- package/dist/assets/{WalkthroughView-Cj3K8Xz6.js → WalkthroughView-BJicp0RC.js} +2 -2
- package/dist/assets/{appThemeRegistry-DAdawJZn.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-RF-KjfUH.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-CL7ZQ94-.js → index-DWB8bR_g.js} +1 -1
- package/dist/assets/index-Di6zUe5h.js +41 -0
- package/dist/assets/{isDiffPlainText-D2yJ9fO6.js → isDiffPlainText-DOoIsGqs.js} +1 -1
- package/dist/assets/{isFilePlainText-BwMEq1Ey.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-quLSA7jc.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-D-lJJibr.js → miniChat-CIWaR8wG.js} +2 -2
- package/dist/assets/{mobile-VTtphbW6.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-DF7ch5eM.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-_HeCCHx1.js → shikiHighlight-l-MbaNS4.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-C95XdFGj.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-ZM9ij19-.js → useAppFontEffects-DDjuwVqi.js} +160 -156
- package/dist/assets/{useInlineCommentController-CkfxMJPy.js → useInlineCommentController-Clg8EO3A.js} +2 -2
- package/dist/assets/{useRootScrollLock-BtiAC1fj.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
- package/dist/assets/{vendor-codemirror-language-data-U-lXzXNK.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-C583Pmfm.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.d.ts +2 -0
- package/server/index.js +196 -52
- package/server/lib/agent-tool/DOCUMENTATION.md +5 -0
- package/server/lib/agent-tool/runtime.js +89 -51
- package/server/lib/agent-tool/runtime.test.js +17 -0
- package/server/lib/browser-control/DOCUMENTATION.md +14 -2
- package/server/lib/event-stream/DOCUMENTATION.md +2 -2
- package/server/lib/event-stream/global-hub.js +45 -7
- package/server/lib/event-stream/global-hub.test.js +128 -2
- package/server/lib/event-stream/global-ws-bridge.js +3 -1
- package/server/lib/event-stream/rebind.test.js +1 -0
- package/server/lib/event-stream/runtime.js +1 -2
- 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/DOCUMENTATION.md +23 -23
- 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 +156 -2
- package/server/lib/opencode/lifecycle.test.js +255 -1
- 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/network-runtime.js +16 -7
- package/server/lib/opencode/network-runtime.test.js +29 -1
- 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 +53 -16
- package/server/lib/opencode/routes-upgrade.test.js +1 -1
- package/server/lib/opencode/routes.js +36 -1
- package/server/lib/opencode/server-startup-runtime.js +25 -0
- package/server/lib/opencode/server-startup-runtime.test.js +67 -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-service-runtime.js +155 -0
- package/server/lib/opencode/shared-service-runtime.test.js +180 -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 +14 -5
- package/server/lib/opencode/shutdown-runtime.test.js +77 -2
- package/server/lib/opencode/skill-routes.js +28 -12
- package/server/lib/opencode/skill-routes.test.js +121 -1
- package/server/lib/opencode/startup-pipeline-runtime.js +5 -4
- package/server/lib/opencode/startup-pipeline-runtime.test.js +18 -7
- 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/watcher.test.js +54 -0
- 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 +271 -0
- package/server/proxy-headers.js +8 -2
- package/server/proxy-headers.test.js +20 -0
- package/dist/assets/DiffView-CX3wAX2S.js +0 -4
- package/dist/assets/FilesView-OL3CON3r.js +0 -17
- package/dist/assets/GuestSurfacePane-B_cqX5I5.js +0 -1
- package/dist/assets/MarkdownImageGallery-DFiTFZgj.js +0 -1
- package/dist/assets/MarkdownRendererImpl-DZm5sf1W.js +0 -12
- package/dist/assets/PluginPane-BibJit86.js +0 -3
- package/dist/assets/SettingsView-h-VpVO47.js +0 -29
- package/dist/assets/commitAndPush-BIPfOMXY.js +0 -1
- package/dist/assets/de-BqCNGROu.js +0 -10
- package/dist/assets/es-BTPpTRgN.js +0 -16
- package/dist/assets/fr-hxbzMdAg.js +0 -14
- package/dist/assets/index-CLcM8HKx.css +0 -1
- package/dist/assets/index-CvLgnuNI.js +0 -41
- package/dist/assets/ja-_zLwwYiy.js +0 -16
- package/dist/assets/ko-CFn3Dzhc.js +0 -16
- package/dist/assets/main-BLJaug9I.js +0 -896
- package/dist/assets/markdownCore-DS9FdwrI.js +0 -5
- package/dist/assets/patchFileDiff-D9iBcTEi.js +0 -13
- package/dist/assets/pl-DFHE7xac.js +0 -16
- package/dist/assets/pt-BR-CUjZF179.js +0 -16
- package/dist/assets/renderMobileApp-CNQhdXsi.js +0 -25
- package/dist/assets/runtimeEndpointReset-DeaERc0M.js +0 -27
- package/dist/assets/scheduledTasksApi-BD6Vx9pu.js +0 -1
- package/dist/assets/tr-BB6sA_zH.js +0 -7
- package/dist/assets/uk-CB4o8g-o.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-BRyDuWhc.js +0 -16
- package/dist/assets/zh-TW-BDGbWA1Q.js +0 -16
|
@@ -0,0 +1,3945 @@
|
|
|
1
|
+
// Code out, with real host git in temporary directories and no Docker. The apply tests run on every
|
|
2
|
+
// platform against a result made on the host; the transfer tests run against the stand-in space of
|
|
3
|
+
// code-in-bait.js, which runs its scripts with a POSIX `sh`. The live file under places/ proves the
|
|
4
|
+
// same path out of a real space.
|
|
5
|
+
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { afterAll, describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { blob, createLocalPlace, createTestHost, forConfig, hostState, makeBait, removeTestHosts, unexpectedChanges } from './code-in-bait.js';
|
|
14
|
+
import { createCodeOut, firstRedirectedFolder, nameNotAllowedHere } from './code-out.js';
|
|
15
|
+
import { SpaceError } from './errors.js';
|
|
16
|
+
|
|
17
|
+
const WIN = process.platform === 'win32';
|
|
18
|
+
const SPACE_ID = 'a1b2c3d4e5f6';
|
|
19
|
+
const OTHER_SPACE_ID = 'f6e5d4c3b2a1';
|
|
20
|
+
const START = `refs/openchamber/spaces/${SPACE_ID}/start`;
|
|
21
|
+
const RESULT = `refs/openchamber/spaces/${SPACE_ID}/result`;
|
|
22
|
+
const APPLIED = `refs/openchamber/spaces/${SPACE_ID}/applied`;
|
|
23
|
+
const CLOSED = `refs/openchamber/spaces/${SPACE_ID}/changes-closed`;
|
|
24
|
+
const APPLYING = `refs/openchamber/spaces/${SPACE_ID}/applying`;
|
|
25
|
+
const APPLIED_FROM = `refs/openchamber/spaces/${SPACE_ID}/applied-from`;
|
|
26
|
+
const APPLYING_FROM = `refs/openchamber/spaces/${SPACE_ID}/applying-from`;
|
|
27
|
+
const APPLIED_HEAD = `refs/openchamber/spaces/${SPACE_ID}/applied-head`;
|
|
28
|
+
const APPLYING_HEAD = `refs/openchamber/spaces/${SPACE_ID}/applying-head`;
|
|
29
|
+
// What a refused apply writes, and the only thing it writes: from then on the space is a branch.
|
|
30
|
+
const CLOSURE = [`.git/refs/openchamber/spaces/${SPACE_ID}/changes-closed`];
|
|
31
|
+
|
|
32
|
+
afterAll(removeTestHosts);
|
|
33
|
+
|
|
34
|
+
/** What changed in a repository folder since `before`, apart from what code in and code out write by design for this space. */
|
|
35
|
+
const changedSince = (before, directory) => unexpectedChanges(before, hostState(directory), { spaceIds: [SPACE_ID], codeOut: true });
|
|
36
|
+
/** What changed at all, with no allowance: nothing of the space reached the repository. */
|
|
37
|
+
const anyChangeSince = (before, directory) => unexpectedChanges(before, hostState(directory), { spaceIds: [] });
|
|
38
|
+
/** The part of a host state under `.git`. */
|
|
39
|
+
const gitOnly = (state) => Object.fromEntries(Object.entries(state).filter(([name]) => name === '.git' || name.startsWith('.git/')));
|
|
40
|
+
/** Temporary folders code out left in the test host's root. */
|
|
41
|
+
const leftFolders = (host) => fs.readdirSync(host.root).filter((name) => name.startsWith('openchamber-code-out-'));
|
|
42
|
+
|
|
43
|
+
/** The tree the working tree of `directory` holds, starting from `treeish`, without touching its index. */
|
|
44
|
+
const workingTreeAs = (host, directory, treeish) => {
|
|
45
|
+
const index = path.join(host.root, `tree-index-${crypto.randomBytes(4).toString('hex')}`);
|
|
46
|
+
const env = { ...host.environment, GIT_INDEX_FILE: index };
|
|
47
|
+
const git = (args) => {
|
|
48
|
+
const result = spawnSync('git', ['-C', directory, ...args], { env, encoding: 'utf8', windowsHide: true });
|
|
49
|
+
if (result.status !== 0) throw new Error(`git ${args.join(' ')}: ${result.stderr}`);
|
|
50
|
+
return result.stdout.trim();
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
git(['read-tree', treeish]);
|
|
54
|
+
git(['add', '--all']);
|
|
55
|
+
return git(['write-tree']);
|
|
56
|
+
} finally {
|
|
57
|
+
fs.rmSync(index, { force: true });
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A result made on the host, for the apply tests on every platform: the start snapshot of the space
|
|
63
|
+
* from code in, then `edit` applied to a scratch checkout of it, committed and fetched into the
|
|
64
|
+
* repository as the space's result ref. Everything here happens before the test looks.
|
|
65
|
+
*/
|
|
66
|
+
const hostResult = async (host, { objectFormat = 'sha1', edit, prepare } = {}) => {
|
|
67
|
+
const bait = makeBait(host, { objectFormat });
|
|
68
|
+
prepare?.(bait);
|
|
69
|
+
const snapshot = await host.codeIn().takeSnapshot({ repository: bait.repo, spaceId: SPACE_ID, mode: 'uncommitted' });
|
|
70
|
+
const scratch = path.join(host.root, `scratch-${crypto.randomBytes(4).toString('hex')}`);
|
|
71
|
+
host.sh(host.root, ['init', '--quiet', `--object-format=${objectFormat}`, scratch]);
|
|
72
|
+
const s = (args, options) => host.sh(scratch, args, options);
|
|
73
|
+
s(['fetch', '--quiet', '--no-write-fetch-head', bait.repo, `${START}:refs/heads/work`]);
|
|
74
|
+
s(['checkout', '--quiet', 'work']);
|
|
75
|
+
edit?.(scratch, s);
|
|
76
|
+
s(['add', '--all']);
|
|
77
|
+
s(['commit', '--quiet', '--allow-empty', '-m', 'the agent']);
|
|
78
|
+
bait.g(['fetch', '--quiet', '--no-write-fetch-head', scratch, `refs/heads/work:${RESULT}`]);
|
|
79
|
+
return { ...bait, snapshot, result: bait.g(['rev-parse', RESULT]).trim() };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const ordinaryEdit = (scratch, s) => {
|
|
83
|
+
fs.writeFileSync(path.join(scratch, 'tracked-to-edit.txt'), 'one\ntwo staged\nthree unstaged\nfour from the space\n');
|
|
84
|
+
fs.writeFileSync(path.join(scratch, 'added by the space.txt'), 'new\n');
|
|
85
|
+
fs.writeFileSync(path.join(scratch, 'binary.bin'), Buffer.from([0, 1, 2, 255, 0, 10, 13, 0]));
|
|
86
|
+
fs.rmSync(path.join(scratch, 'README.md'));
|
|
87
|
+
fs.writeFileSync(path.join(scratch, 'tool.sh'), '#!/bin/sh\necho tool\n');
|
|
88
|
+
if (!WIN) fs.chmodSync(path.join(scratch, 'tool.sh'), 0o755);
|
|
89
|
+
s(['add', 'tool.sh']);
|
|
90
|
+
s(['update-index', '--chmod=+x', 'tool.sh']);
|
|
91
|
+
if (!WIN) fs.symlinkSync('/etc/passwd', path.join(scratch, 'link-outside'));
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
describe('unexpectedChanges for code out', () => {
|
|
95
|
+
it('allows a new pack and the result ref of this space only with codeOut, and moves of the result ref', () => {
|
|
96
|
+
const host = createTestHost();
|
|
97
|
+
const { repo, g } = makeBait(host);
|
|
98
|
+
g(['update-ref', RESULT, 'HEAD~1']);
|
|
99
|
+
g(['update-ref', APPLIED, 'HEAD~1']);
|
|
100
|
+
g(['update-ref', CLOSED, 'HEAD~1']);
|
|
101
|
+
g(['update-ref', APPLYING, 'HEAD~1']);
|
|
102
|
+
g(['update-ref', APPLIED_FROM, 'HEAD~1']);
|
|
103
|
+
g(['update-ref', APPLYING_FROM, 'HEAD~1']);
|
|
104
|
+
g(['update-ref', APPLIED_HEAD, 'HEAD~1']);
|
|
105
|
+
g(['update-ref', APPLYING_HEAD, 'HEAD~1']);
|
|
106
|
+
const before = hostState(repo);
|
|
107
|
+
g(['repack', '-q', '-n']);
|
|
108
|
+
g(['update-ref', RESULT, 'HEAD']);
|
|
109
|
+
g(['update-ref', APPLIED, 'HEAD']);
|
|
110
|
+
g(['update-ref', CLOSED, 'HEAD']);
|
|
111
|
+
g(['update-ref', APPLYING, 'HEAD']);
|
|
112
|
+
g(['update-ref', APPLIED_FROM, 'HEAD']);
|
|
113
|
+
g(['update-ref', APPLYING_FROM, 'HEAD']);
|
|
114
|
+
g(['update-ref', APPLIED_HEAD, 'HEAD']);
|
|
115
|
+
g(['update-ref', APPLYING_HEAD, 'HEAD']);
|
|
116
|
+
const added = Object.keys(hostState(repo)).filter((name) => /^\.git\/objects\/pack\/pack-/.test(name) && before[name] === undefined);
|
|
117
|
+
expect(added.length).toBeGreaterThan(0);
|
|
118
|
+
expect(changedSince(before, repo)).toEqual([]);
|
|
119
|
+
// Without the code out allowance, the same changes all show.
|
|
120
|
+
expect(unexpectedChanges(before, hostState(repo), { spaceIds: [SPACE_ID] }))
|
|
121
|
+
.toEqual([...added, ...['applied', 'applied-from', 'applied-head', 'applying', 'applying-from', 'applying-head', 'changes-closed', 'result'].map((name) => `.git/refs/openchamber/spaces/${SPACE_ID}/${name}`)].sort());
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it.each([
|
|
125
|
+
['the result ref of another space', (repo, g) => g(['update-ref', `refs/openchamber/spaces/${OTHER_SPACE_ID}/result`, 'HEAD']), `.git/refs/openchamber/spaces/${OTHER_SPACE_ID}/result`],
|
|
126
|
+
['a branch', (repo, g) => g(['update-ref', 'refs/heads/from-the-space', 'HEAD']), '.git/refs/heads/from-the-space'],
|
|
127
|
+
['FETCH_HEAD', (repo) => fs.writeFileSync(path.join(repo, '.git', 'FETCH_HEAD'), 'x\n'), '.git/FETCH_HEAD'],
|
|
128
|
+
['a shallow file', (repo) => fs.writeFileSync(path.join(repo, '.git', 'shallow'), 'x\n'), '.git/shallow'],
|
|
129
|
+
['a commit graph', (repo) => fs.writeFileSync(path.join(repo, '.git', 'objects', 'info', 'commit-graph'), 'x'), '.git/objects/info/commit-graph'],
|
|
130
|
+
['a pack rewritten', (repo) => {
|
|
131
|
+
const pack = fs.readdirSync(path.join(repo, '.git', 'objects', 'pack')).find((name) => name.endsWith('.pack'));
|
|
132
|
+
fs.chmodSync(path.join(repo, '.git', 'objects', 'pack', pack), 0o644);
|
|
133
|
+
fs.appendFileSync(path.join(repo, '.git', 'objects', 'pack', pack), 'x');
|
|
134
|
+
}, null],
|
|
135
|
+
['a changed working tree file', (repo) => fs.writeFileSync(path.join(repo, 'README.md'), 'changed\n'), 'README.md'],
|
|
136
|
+
])('still sees %s', (_, change, expectedPath) => {
|
|
137
|
+
const host = createTestHost();
|
|
138
|
+
const { repo, g } = makeBait(host);
|
|
139
|
+
g(['repack', '-q', '-n']);
|
|
140
|
+
const before = hostState(repo);
|
|
141
|
+
change(repo, g);
|
|
142
|
+
const changed = changedSince(before, repo);
|
|
143
|
+
expect(changed.length).toBeGreaterThan(0);
|
|
144
|
+
if (expectedPath !== null) expect(changed).toContain(expectedPath);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('applyAsChanges', () => {
|
|
149
|
+
it('writes exactly the result into the working tree, and leaves the index, HEAD and .git alone', async () => {
|
|
150
|
+
const host = createTestHost();
|
|
151
|
+
const { repo, g, result } = await hostResult(host, { edit: ordinaryEdit });
|
|
152
|
+
const head = g(['rev-parse', 'HEAD']).trim();
|
|
153
|
+
const before = hostState(repo);
|
|
154
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toEqual({
|
|
155
|
+
status: 'applied', appliedPaths: WIN ? 5 : 6, remembered: true, nestedRepositories: { count: 0, paths: [] }, conflicted: { count: 0, paths: [] },
|
|
156
|
+
});
|
|
157
|
+
expect(workingTreeAs(host, repo, result)).toBe(g(['rev-parse', `${result}^{tree}`]).trim());
|
|
158
|
+
if (!WIN) {
|
|
159
|
+
expect(fs.statSync(path.join(repo, 'tool.sh')).mode & 0o111).not.toBe(0);
|
|
160
|
+
expect(fs.readlinkSync(path.join(repo, 'link-outside'))).toBe('/etc/passwd');
|
|
161
|
+
}
|
|
162
|
+
expect(fs.existsSync(path.join(repo, 'README.md'))).toBe(false);
|
|
163
|
+
expect(g(['rev-parse', 'HEAD']).trim()).toBe(head);
|
|
164
|
+
// Nothing in `.git` but the refs that remember what was applied, from where, and HEAD then.
|
|
165
|
+
expect(unexpectedChanges(gitOnly(before), gitOnly(hostState(repo)), { spaceIds: [SPACE_ID], codeOut: true })).toEqual([]);
|
|
166
|
+
expect(unexpectedChanges(gitOnly(before), gitOnly(hostState(repo)))).toEqual(['applied', 'applied-from', 'applied-head'].map((name) => `.git/refs/openchamber/spaces/${SPACE_ID}/${name}`));
|
|
167
|
+
expect(leftFolders(host)).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('says there is nothing to apply when the space changed nothing, and changes nothing', async () => {
|
|
171
|
+
const host = createTestHost();
|
|
172
|
+
const { repo } = await hostResult(host);
|
|
173
|
+
const before = hostState(repo);
|
|
174
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toEqual({ status: 'nothing_to_apply' });
|
|
175
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it.each([
|
|
179
|
+
['the user edited the same line since', (repo) => fs.writeFileSync(path.join(repo, 'tracked-to-edit.txt'), 'one\ntwo staged\nthree changed on the host\n')],
|
|
180
|
+
['the user has an untracked file where the space adds one', (repo) => fs.writeFileSync(path.join(repo, 'added by the space.txt'), 'mine\n')],
|
|
181
|
+
['the user deleted a file the space changes', (repo) => fs.rmSync(path.join(repo, 'tracked-to-edit.txt'))],
|
|
182
|
+
])('touches nothing and offers the branch when %s', async (_, userChange) => {
|
|
183
|
+
const host = createTestHost();
|
|
184
|
+
const { repo } = await hostResult(host, { edit: ordinaryEdit });
|
|
185
|
+
userChange(repo);
|
|
186
|
+
const before = hostState(repo);
|
|
187
|
+
const failure = await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
188
|
+
expect(failure).toMatchObject({ code: 'changes_do_not_apply' });
|
|
189
|
+
expect(failure.message).toMatch(/nothing was changed now/);
|
|
190
|
+
expect(failure.message).toMatch(/since the space was made/);
|
|
191
|
+
expect(failure.message).toMatch(/applied as a branch/);
|
|
192
|
+
expect(anyChangeSince(before, repo)).toEqual(CLOSURE);
|
|
193
|
+
expect(leftFolders(host)).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('refuses without a result or a start, and changes nothing', async () => {
|
|
197
|
+
const host = createTestHost();
|
|
198
|
+
const { repo, g } = makeBait(host);
|
|
199
|
+
const before = hostState(repo);
|
|
200
|
+
await expect(host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).rejects.toMatchObject({ code: 'space_start_missing' });
|
|
201
|
+
g(['update-ref', START, 'HEAD']);
|
|
202
|
+
await expect(host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).rejects.toMatchObject({ code: 'result_missing' });
|
|
203
|
+
await expect(host.codeOut().applyAsChanges(undefined)).rejects.toMatchObject({ code: 'invalid_space_id' });
|
|
204
|
+
expect(unexpectedChanges(before, hostState(repo), { spaceIds: [SPACE_ID] })).toEqual([]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// The user's own git config broke the obvious commands in the maintainer's probe. Each case first
|
|
208
|
+
// shows the obvious command failing with that config, then code out working with it.
|
|
209
|
+
const whitespaceEdit = (scratch) => fs.writeFileSync(path.join(scratch, 'tracked-to-edit.txt'), 'one\ntwo staged\nthree unstaged\ntrailing spaces \n');
|
|
210
|
+
|
|
211
|
+
it('applies the exact bytes for a user with apply.whitespace=fix, which silently changes them otherwise', async () => {
|
|
212
|
+
const host = createTestHost();
|
|
213
|
+
const { repo, g, snapshot, result } = await hostResult(host, { edit: whitespaceEdit });
|
|
214
|
+
host.addConfig('[apply]\n\twhitespace = fix');
|
|
215
|
+
// The control, in a copy: a plain apply of the same patch strips the trailing spaces and says nothing.
|
|
216
|
+
const control = makeBait(host, { name: 'control' });
|
|
217
|
+
const patch = path.join(host.root, 'control.patch');
|
|
218
|
+
g(['diff-tree', '-p', '--binary', `--output=${forConfig(patch)}`, snapshot.start, result]);
|
|
219
|
+
control.g(['apply', forConfig(patch)]);
|
|
220
|
+
expect(fs.readFileSync(path.join(control.repo, 'tracked-to-edit.txt'), 'utf8')).toContain('trailing spaces\n');
|
|
221
|
+
|
|
222
|
+
await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID });
|
|
223
|
+
expect(fs.readFileSync(path.join(repo, 'tracked-to-edit.txt'), 'utf8')).toContain('trailing spaces \n');
|
|
224
|
+
expect(workingTreeAs(host, repo, result)).toBe(g(['rev-parse', `${result}^{tree}`]).trim());
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('applies for a user with apply.whitespace=error, which refuses otherwise', async () => {
|
|
228
|
+
const host = createTestHost();
|
|
229
|
+
const { repo, g, snapshot, result } = await hostResult(host, { edit: whitespaceEdit });
|
|
230
|
+
host.addConfig('[apply]\n\twhitespace = error');
|
|
231
|
+
const patch = path.join(host.root, 'control.patch');
|
|
232
|
+
g(['diff-tree', '-p', '--binary', `--output=${forConfig(patch)}`, snapshot.start, result]);
|
|
233
|
+
expect(spawnSync('git', ['-C', repo, 'apply', '--check', forConfig(patch)], { env: host.environment, windowsHide: true }).status).not.toBe(0);
|
|
234
|
+
|
|
235
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied' });
|
|
236
|
+
expect(workingTreeAs(host, repo, result)).toBe(g(['rev-parse', `${result}^{tree}`]).trim());
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('respects every whitespace difference for a user with apply.ignoreWhitespace=change', async () => {
|
|
240
|
+
const host = createTestHost();
|
|
241
|
+
const { repo, g, snapshot, result } = await hostResult(host, { edit: whitespaceEdit });
|
|
242
|
+
host.addConfig('[apply]\n\tignoreWhitespace = change');
|
|
243
|
+
// The user changed the whitespace of a line the patch has as context.
|
|
244
|
+
fs.writeFileSync(path.join(repo, 'tracked-to-edit.txt'), 'one\ntwo staged\nthree unstaged\n');
|
|
245
|
+
const patch = path.join(host.root, 'control.patch');
|
|
246
|
+
g(['diff-tree', '-p', '--binary', `--output=${forConfig(patch)}`, snapshot.start, result]);
|
|
247
|
+
// The control: with that config the patch lands on a line it does not match.
|
|
248
|
+
expect(spawnSync('git', ['-C', repo, 'apply', '--check', forConfig(patch)], { env: host.environment, windowsHide: true }).status).toBe(0);
|
|
249
|
+
const before = hostState(repo);
|
|
250
|
+
await expect(host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).rejects.toMatchObject({ code: 'changes_do_not_apply' });
|
|
251
|
+
expect(anyChangeSince(before, repo)).toEqual(CLOSURE);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('builds a patch that applies for a user with diff.noprefix, where the porcelain diff does not', async () => {
|
|
255
|
+
const host = createTestHost();
|
|
256
|
+
const { repo, g, snapshot, result } = await hostResult(host, { edit: ordinaryEdit });
|
|
257
|
+
host.addConfig('[diff]\n\tnoprefix = true');
|
|
258
|
+
const control = makeBait(host, { name: 'control' });
|
|
259
|
+
const patch = path.join(host.root, 'control.patch');
|
|
260
|
+
g(['diff', '--binary', `--output=${forConfig(patch)}`, snapshot.start, result]);
|
|
261
|
+
expect(spawnSync('git', ['-C', control.repo, 'apply', '--check', forConfig(patch)], { env: host.environment, windowsHide: true }).status).not.toBe(0);
|
|
262
|
+
|
|
263
|
+
await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID });
|
|
264
|
+
expect(workingTreeAs(host, repo, result)).toBe(g(['rev-parse', `${result}^{tree}`]).trim());
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('applies the whole result from a project subfolder for a user with diff.relative, where the porcelain diff loses paths', async () => {
|
|
268
|
+
const host = createTestHost();
|
|
269
|
+
const { repo, g, snapshot, result } = await hostResult(host, {
|
|
270
|
+
edit: (scratch) => {
|
|
271
|
+
fs.mkdirSync(path.join(scratch, 'packages', 'app'), { recursive: true });
|
|
272
|
+
fs.writeFileSync(path.join(scratch, 'packages', 'app', 'index.js'), 'app\n');
|
|
273
|
+
fs.writeFileSync(path.join(scratch, 'top-level.txt'), 'outside the subfolder\n');
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
fs.mkdirSync(path.join(repo, 'packages', 'app'), { recursive: true });
|
|
277
|
+
host.addConfig('[diff]\n\trelative = true');
|
|
278
|
+
const subfolder = path.join(repo, 'packages');
|
|
279
|
+
const porcelain = host.sh(subfolder, ['diff', '--name-only', snapshot.start, result]);
|
|
280
|
+
expect(porcelain).not.toContain('top-level.txt');
|
|
281
|
+
|
|
282
|
+
await host.codeOut().applyAsChanges({ repository: subfolder, spaceId: SPACE_ID });
|
|
283
|
+
expect(fs.readFileSync(path.join(repo, 'top-level.txt'), 'utf8')).toBe('outside the subfolder\n');
|
|
284
|
+
expect(workingTreeAs(host, repo, result)).toBe(g(['rev-parse', `${result}^{tree}`]).trim());
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('works in a SHA-256 repository', async () => {
|
|
288
|
+
const host = createTestHost();
|
|
289
|
+
const { repo, g, result } = await hostResult(host, { objectFormat: 'sha256', edit: ordinaryEdit });
|
|
290
|
+
expect(result).toMatch(/^[0-9a-f]{64}$/);
|
|
291
|
+
await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID });
|
|
292
|
+
expect(workingTreeAs(host, repo, result)).toBe(g(['rev-parse', `${result}^{tree}`]).trim());
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Git must not be trusted with this. Measured on Windows 11 with Git for Windows 2.54: `git apply`
|
|
296
|
+
// wrote through a directory junction into the folder it points at, where git on POSIX refuses. The
|
|
297
|
+
// host looks at every folder on the way itself, and the test builds a junction on Windows.
|
|
298
|
+
it.each([
|
|
299
|
+
['a file the space adds under it', (scratch) => {
|
|
300
|
+
fs.mkdirSync(path.join(scratch, 'cache'));
|
|
301
|
+
fs.writeFileSync(path.join(scratch, 'cache', 'planted.txt'), 'through the link\n');
|
|
302
|
+
}, null],
|
|
303
|
+
['a file the space deletes under it', (scratch) => fs.rmSync(path.join(scratch, 'cache', 'kept.txt')), 'kept.txt'],
|
|
304
|
+
['a folder several levels under it', (scratch) => {
|
|
305
|
+
fs.mkdirSync(path.join(scratch, 'cache', 'deep', 'deeper'), { recursive: true });
|
|
306
|
+
fs.writeFileSync(path.join(scratch, 'cache', 'deep', 'deeper', 'planted.txt'), 'through the link\n');
|
|
307
|
+
}, null],
|
|
308
|
+
])('refuses to write through a link the user has in the working tree: %s', async (_, edit, userFile) => {
|
|
309
|
+
const host = createTestHost();
|
|
310
|
+
const outside = path.join(host.root, 'outside');
|
|
311
|
+
fs.mkdirSync(outside);
|
|
312
|
+
const { repo } = await hostResult(host, {
|
|
313
|
+
prepare: userFile === null ? undefined : ({ repo: bait, g }) => {
|
|
314
|
+
fs.mkdirSync(path.join(bait, 'cache'));
|
|
315
|
+
fs.writeFileSync(path.join(bait, 'cache', userFile), 'the user\'s\n');
|
|
316
|
+
g(['add', 'cache']);
|
|
317
|
+
g(['commit', '--quiet', '-m', 'a folder of the user', '--', 'cache']);
|
|
318
|
+
},
|
|
319
|
+
edit,
|
|
320
|
+
});
|
|
321
|
+
// The folder becomes a link on the host, to a place outside the repository that holds the same file.
|
|
322
|
+
if (userFile !== null) {
|
|
323
|
+
fs.cpSync(path.join(repo, 'cache'), outside, { recursive: true });
|
|
324
|
+
fs.rmSync(path.join(repo, 'cache'), { recursive: true });
|
|
325
|
+
} else {
|
|
326
|
+
fs.appendFileSync(path.join(repo, '.git', 'info', 'exclude'), 'cache\n');
|
|
327
|
+
}
|
|
328
|
+
fs.symlinkSync(outside, path.join(repo, 'cache'), WIN ? 'junction' : 'dir');
|
|
329
|
+
const outsideBefore = fs.readdirSync(outside).sort();
|
|
330
|
+
const before = hostState(repo);
|
|
331
|
+
const failure = await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
332
|
+
expect(failure).toMatchObject({ code: 'changes_blocked_by_link', details: { path: 'cache' } });
|
|
333
|
+
expect(failure.message).toMatch(/is a link to another place, so nothing was changed/);
|
|
334
|
+
expect(failure.message).toMatch(/applied as a branch/);
|
|
335
|
+
expect(fs.readdirSync(outside).sort()).toEqual(outsideBefore);
|
|
336
|
+
expect(anyChangeSince(before, repo)).toEqual(CLOSURE);
|
|
337
|
+
// It is the same family as a collision: the route is closed.
|
|
338
|
+
await expect(host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).rejects.toMatchObject({ code: 'changes_route_closed' });
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it.skipIf(WIN)('refuses a link that points inside the working tree as well, and applies around a link the patch does not pass', async () => {
|
|
342
|
+
const host = createTestHost();
|
|
343
|
+
const { repo } = await hostResult(host, {
|
|
344
|
+
edit: (scratch) => {
|
|
345
|
+
fs.mkdirSync(path.join(scratch, 'docs'));
|
|
346
|
+
fs.writeFileSync(path.join(scratch, 'docs', 'new.md'), 'new\n');
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
fs.mkdirSync(path.join(repo, 'documentation'));
|
|
350
|
+
fs.appendFileSync(path.join(repo, '.git', 'info', 'exclude'), 'docs\ndocumentation\nelsewhere\n');
|
|
351
|
+
fs.symlinkSync('documentation', path.join(repo, 'docs'), 'dir');
|
|
352
|
+
await expect(host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).rejects.toMatchObject({ code: 'changes_blocked_by_link', details: { path: 'docs' } });
|
|
353
|
+
expect(fs.readdirSync(path.join(repo, 'documentation'))).toEqual([]);
|
|
354
|
+
|
|
355
|
+
// The control, in a fresh repository: a link beside the patch's paths is none of its business.
|
|
356
|
+
const clean = createTestHost();
|
|
357
|
+
const other = await hostResult(clean, {
|
|
358
|
+
edit: (scratch) => {
|
|
359
|
+
fs.mkdirSync(path.join(scratch, 'docs'));
|
|
360
|
+
fs.writeFileSync(path.join(scratch, 'docs', 'new.md'), 'new\n');
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
fs.appendFileSync(path.join(other.repo, '.git', 'info', 'exclude'), 'elsewhere\n');
|
|
364
|
+
fs.symlinkSync(clean.root, path.join(other.repo, 'elsewhere'), 'dir');
|
|
365
|
+
expect(await clean.codeOut().applyAsChanges({ repository: other.repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied' });
|
|
366
|
+
expect(fs.readFileSync(path.join(other.repo, 'docs', 'new.md'), 'utf8')).toBe('new\n');
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// The user's own filters run when the patch is applied, as they would for a `git pull`; a filter
|
|
371
|
+
// named by a `.gitattributes` that arrives in the patch does not. The branch runs nothing.
|
|
372
|
+
describe.skipIf(WIN)('filters', () => {
|
|
373
|
+
const filterHost = () => {
|
|
374
|
+
const host = createTestHost();
|
|
375
|
+
const markers = path.join(host.root, 'filter-markers');
|
|
376
|
+
fs.mkdirSync(markers);
|
|
377
|
+
const filter = (name, step) => {
|
|
378
|
+
const program = path.join(host.root, `${name}-${step}.sh`);
|
|
379
|
+
fs.writeFileSync(program, `#!/bin/sh\necho ran >> '${markers}/${name}-${step}'\ncat\n`, { mode: 0o755 });
|
|
380
|
+
return forConfig(program);
|
|
381
|
+
};
|
|
382
|
+
host.addConfig([
|
|
383
|
+
'[filter "mine"]', `\tclean = ${filter('mine', 'clean')}`, `\tsmudge = ${filter('mine', 'smudge')}`,
|
|
384
|
+
'[filter "arriving"]', `\tclean = ${filter('arriving', 'clean')}`, `\tsmudge = ${filter('arriving', 'smudge')}`,
|
|
385
|
+
].join('\n'));
|
|
386
|
+
const ran = () => fs.readdirSync(markers).sort();
|
|
387
|
+
const clear = () => { for (const marker of fs.readdirSync(markers)) fs.rmSync(path.join(markers, marker)); };
|
|
388
|
+
return { host, ran, clear };
|
|
389
|
+
};
|
|
390
|
+
const edit = (scratch) => {
|
|
391
|
+
fs.writeFileSync(path.join(scratch, 'data.mine'), 'changed by the space\n');
|
|
392
|
+
fs.mkdirSync(path.join(scratch, 'arrived'));
|
|
393
|
+
fs.writeFileSync(path.join(scratch, 'arrived', '.gitattributes'), '*.bin filter=arriving\n');
|
|
394
|
+
fs.writeFileSync(path.join(scratch, 'arrived', 'a.bin'), 'bin\n');
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
it('runs the user\'s own filter for a file their .gitattributes covers, and not one a patch brings', async () => {
|
|
398
|
+
const { host, ran, clear } = filterHost();
|
|
399
|
+
// The control: both filters run on an ordinary command, each where its attributes are.
|
|
400
|
+
const control = makeBait(host, { name: 'control' });
|
|
401
|
+
fs.writeFileSync(path.join(control.repo, '.gitattributes'), '*.mine filter=mine\n*.bin filter=arriving\n');
|
|
402
|
+
fs.writeFileSync(path.join(control.repo, 'x.mine'), 'x\n');
|
|
403
|
+
fs.writeFileSync(path.join(control.repo, 'x.bin'), 'x\n');
|
|
404
|
+
control.g(['add', 'x.mine', 'x.bin']);
|
|
405
|
+
expect(ran()).toEqual(['arriving-clean', 'mine-clean']);
|
|
406
|
+
clear();
|
|
407
|
+
|
|
408
|
+
// The user's repository has the attributes and the file before the space's change arrives.
|
|
409
|
+
const { repo: user } = await hostResult(host, {
|
|
410
|
+
prepare: ({ repo, g }) => {
|
|
411
|
+
fs.writeFileSync(path.join(repo, '.gitattributes'), '*.mine filter=mine\n');
|
|
412
|
+
fs.writeFileSync(path.join(repo, 'data.mine'), 'data\n');
|
|
413
|
+
g(['add', '.gitattributes', 'data.mine']);
|
|
414
|
+
g(['commit', '--quiet', '-m', 'the user has a filter', '--', '.gitattributes', 'data.mine']);
|
|
415
|
+
},
|
|
416
|
+
edit,
|
|
417
|
+
});
|
|
418
|
+
clear();
|
|
419
|
+
|
|
420
|
+
await host.codeOut().applyAsBranch({ repository: user, spaceId: SPACE_ID, branch: 'from-the-space' });
|
|
421
|
+
expect(ran()).toEqual([]);
|
|
422
|
+
await host.codeOut().applyAsChanges({ repository: user, spaceId: SPACE_ID });
|
|
423
|
+
expect(fs.readFileSync(path.join(user, 'data.mine'), 'utf8')).toBe('changed by the space\n');
|
|
424
|
+
expect(fs.readFileSync(path.join(user, 'arrived', 'a.bin'), 'utf8')).toBe('bin\n');
|
|
425
|
+
expect(ran()).toContain('mine-smudge');
|
|
426
|
+
expect(ran().filter((marker) => marker.startsWith('arriving'))).toEqual([]);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// The dry run passes and the apply itself does not: a folder that cannot be written, or a full disk.
|
|
431
|
+
// Part of the work is then in the working tree, and nothing else may pretend otherwise.
|
|
432
|
+
describe.skipIf(WIN || process.getuid?.() === 0)('an apply that stops in the middle', () => {
|
|
433
|
+
const lockedResult = async (host) => hostResult(host, {
|
|
434
|
+
prepare: ({ repo, g }) => {
|
|
435
|
+
fs.mkdirSync(path.join(repo, 'locked'));
|
|
436
|
+
fs.writeFileSync(path.join(repo, 'locked', 'keep.txt'), 'one\n');
|
|
437
|
+
g(['add', 'locked/keep.txt']);
|
|
438
|
+
g(['commit', '--quiet', '-m', 'a folder of the user', '--', 'locked/keep.txt']);
|
|
439
|
+
},
|
|
440
|
+
edit: (scratch) => {
|
|
441
|
+
fs.writeFileSync(path.join(scratch, 'locked', 'keep.txt'), 'one\ntwo from the space\n');
|
|
442
|
+
fs.writeFileSync(path.join(scratch, 'plain.txt'), 'plain\n');
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('says that part of the work may be in the project, and does not remember the apply', async () => {
|
|
447
|
+
const host = createTestHost();
|
|
448
|
+
const { repo, g, result } = await lockedResult(host);
|
|
449
|
+
fs.chmodSync(path.join(repo, 'locked'), 0o500);
|
|
450
|
+
try {
|
|
451
|
+
const failure = await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
452
|
+
expect(failure).toMatchObject({ code: 'changes_partly_applied' });
|
|
453
|
+
expect(failure.message).toMatch(/part of it may be in your project/);
|
|
454
|
+
expect(failure.message).toMatch(/as a branch/);
|
|
455
|
+
// The work is not in the project: the locked file kept its content, and which of the other
|
|
456
|
+
// files git had already written when it stopped is its own order, which is why the message
|
|
457
|
+
// says that part of it may be there.
|
|
458
|
+
expect(fs.readFileSync(path.join(repo, 'locked', 'keep.txt'), 'utf8')).toBe('one\n');
|
|
459
|
+
expect(workingTreeAs(host, repo, result)).not.toBe(g(['rev-parse', `${result}^{tree}`]).trim());
|
|
460
|
+
// Nothing was remembered, so no later apply may think this one went through.
|
|
461
|
+
expect(g(['for-each-ref', APPLIED])).toBe('');
|
|
462
|
+
// The intent went with the closure, in the same transaction, so nothing reports it as interrupted.
|
|
463
|
+
expect(failure.details.recorded).toBe(true);
|
|
464
|
+
expect(g(['for-each-ref', APPLYING])).toBe('');
|
|
465
|
+
// And the route is closed: the project is in a state this cannot reason about.
|
|
466
|
+
expect(failure.message).toMatch(/applied as a branch/);
|
|
467
|
+
expect(g(['rev-parse', CLOSED]).trim()).toBe(result);
|
|
468
|
+
await expect(host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).rejects.toMatchObject({ code: 'changes_route_closed' });
|
|
469
|
+
} finally {
|
|
470
|
+
fs.chmodSync(path.join(repo, 'locked'), 0o700);
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
// The control: with the folder writable the same result applies and is remembered.
|
|
475
|
+
it('applies and remembers when the folder can be written', async () => {
|
|
476
|
+
const host = createTestHost();
|
|
477
|
+
const { repo, g, result } = await lockedResult(host);
|
|
478
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied', remembered: true });
|
|
479
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(result);
|
|
480
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
describe('a host git that fails where code out has to answer for it', () => {
|
|
485
|
+
/** The host's git, except that the call whose arguments `breaks` names fails. */
|
|
486
|
+
const gitBreaking = (host, breaks) => ({
|
|
487
|
+
run: (directory, args, options) => (breaks(args, options) ? Promise.resolve({ code: 128, stdout: '', stderr: 'fatal: broken on purpose\n' }) : host.git.run(directory, args, options)),
|
|
488
|
+
output: async (directory, args, options) => {
|
|
489
|
+
if (breaks(args, options)) throw new SpaceError('git_command_failed', 'git diff-tree failed: fatal: unable to generate diff', { exitCode: 128 });
|
|
490
|
+
return host.git.output(directory, args, options);
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('says a patch could not be built at all, and points at the branch', async () => {
|
|
495
|
+
const host = createTestHost();
|
|
496
|
+
const { repo } = await hostResult(host, { edit: ordinaryEdit });
|
|
497
|
+
const codeOut = createCodeOut({ git: gitBreaking(host, (args) => args.some((argument) => String(argument).startsWith('--output='))), place: null, temporaryDirectory: host.root });
|
|
498
|
+
const before = hostState(repo);
|
|
499
|
+
const failure = await codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
500
|
+
expect(failure).toMatchObject({ code: 'patch_not_possible', details: { cause: 'git_command_failed' } });
|
|
501
|
+
expect(failure.message).toMatch(/as a branch/);
|
|
502
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('says the apply was not remembered when the ref cannot be written, and leaves the changes in place', async () => {
|
|
506
|
+
const host = createTestHost();
|
|
507
|
+
const { repo, g, result } = await hostResult(host, { edit: ordinaryEdit });
|
|
508
|
+
// The record after the apply is one transaction on stdin; it is what fails here.
|
|
509
|
+
const record = (args, options) => args.includes('update-ref') && String(options?.stdin ?? '').startsWith(`update ${APPLIED} `);
|
|
510
|
+
const codeOut = createCodeOut({ git: gitBreaking(host, record), place: null, temporaryDirectory: host.root });
|
|
511
|
+
expect(await codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied', remembered: false });
|
|
512
|
+
expect(workingTreeAs(host, repo, result)).toBe(g(['rev-parse', `${result}^{tree}`]).trim());
|
|
513
|
+
expect(g(['for-each-ref', APPLIED])).toBe('');
|
|
514
|
+
// The intent stayed, so the next call finds the work in the working tree and finishes the record.
|
|
515
|
+
expect(g(['rev-parse', APPLYING]).trim()).toBe(result);
|
|
516
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toEqual({ status: 'nothing_to_apply' });
|
|
517
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(result);
|
|
518
|
+
expect(g(['for-each-ref', APPLYING, CLOSED])).toBe('');
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// Each of the two ways the check sees a folder that leads elsewhere, alone. On a POSIX machine a
|
|
523
|
+
// symbolic link trips both, so these fakes stand for what only other systems produce: a junction
|
|
524
|
+
// that `lstat` reports as a link and whose real path Node may not resolve, and a redirection that
|
|
525
|
+
// `lstat` reports as a plain folder.
|
|
526
|
+
describe('firstRedirectedFolder', () => {
|
|
527
|
+
const root = path.join(path.sep, 'work');
|
|
528
|
+
const folder = { isSymbolicLink: () => false, isDirectory: () => true };
|
|
529
|
+
const link = { isSymbolicLink: () => true, isDirectory: () => false };
|
|
530
|
+
const fakes = (kinds, reals = {}) => ({
|
|
531
|
+
lstat: async (full) => {
|
|
532
|
+
const relative = path.relative(root, full).split(path.sep).join('/');
|
|
533
|
+
if (!(relative in kinds)) throw Object.assign(new Error('absent'), { code: 'ENOENT' });
|
|
534
|
+
return kinds[relative];
|
|
535
|
+
},
|
|
536
|
+
realpath: async (full) => {
|
|
537
|
+
const relative = path.relative(root, full).split(path.sep).join('/');
|
|
538
|
+
return relative in reals ? reals[relative] : full;
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('sees a folder that lstat reports as a link, even when its real path looks like its own', async () => {
|
|
543
|
+
const check = fakes({ a: folder, 'a/b': link });
|
|
544
|
+
expect(await firstRedirectedFolder(root, ['a/b/c/file.txt'], { ignoreCase: true, ...check })).toBe('a/b');
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// The patch deletes the link `Lnk` and writes into `lnk`: on a disk that ignores case, `lstat` of
|
|
548
|
+
// `lnk` finds the link, which the patch does not delete under that spelling.
|
|
549
|
+
it('passes over a link the patch deletes only under the exact spelling the patch deletes', async () => {
|
|
550
|
+
const check = fakes({ lnk: link });
|
|
551
|
+
expect(await firstRedirectedFolder(root, ['lnk/evil.txt'], { ignoreCase: true, deletedLinks: ['Lnk'], ...check })).toBe('lnk');
|
|
552
|
+
expect(await firstRedirectedFolder(root, ['lnk/evil.txt'], { ignoreCase: true, deletedLinks: ['lnk'], ...check })).toBeNull();
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('sees a plain folder whose real path is somewhere else', async () => {
|
|
556
|
+
const check = fakes({ a: folder }, { a: path.join(path.sep, 'elsewhere') });
|
|
557
|
+
expect(await firstRedirectedFolder(root, ['a/file.txt'], check)).toBe('a');
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('lets plain folders, folders that do not exist yet, and a different case where the repository ignores case through', async () => {
|
|
561
|
+
const check = fakes({ a: folder, 'a/b': folder }, { 'a/b': path.join(root, 'A', 'B') });
|
|
562
|
+
expect(await firstRedirectedFolder(root, ['a/b/file.txt', 'new/deeper/file.txt', 'top.txt'], { ignoreCase: true, ...check })).toBeNull();
|
|
563
|
+
// The same answer is a different folder where case counts.
|
|
564
|
+
expect(await firstRedirectedFolder(root, ['a/b/file.txt'], check)).toBe('a/b');
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// Every path of the patch is walked, whatever comes before it: a file at the top, with no folder on
|
|
568
|
+
// its way, and a path through plain folders first.
|
|
569
|
+
it('looks at every path, not only the first, and past a path with no folder on its way', async () => {
|
|
570
|
+
const check = fakes({ plain: folder, 'plain/deeper': folder, a: folder, 'a/b': link });
|
|
571
|
+
expect(await firstRedirectedFolder(root, ['top.txt', 'plain/deeper/file.txt', 'a/b/c/file.txt'], check)).toBe('a/b');
|
|
572
|
+
const elsewhere = fakes({ plain: folder, a: folder }, { a: path.join(path.sep, 'elsewhere') });
|
|
573
|
+
expect(await firstRedirectedFolder(root, ['top.txt', 'plain/file.txt', 'a/file.txt'], elsewhere)).toBe('a');
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('passes over a link the patch deletes as a link, and no other', async () => {
|
|
577
|
+
const check = fakes({ lib: link, other: link });
|
|
578
|
+
expect(await firstRedirectedFolder(root, ['lib', 'lib/own.txt'], { ...check, deletedLinks: ['lib'] })).toBeNull();
|
|
579
|
+
// The control: the same link, with the patch deleting a different one, or none.
|
|
580
|
+
expect(await firstRedirectedFolder(root, ['lib/own.txt'], { ...check, deletedLinks: ['other'] })).toBe('lib');
|
|
581
|
+
expect(await firstRedirectedFolder(root, ['lib/own.txt'], check)).toBe('lib');
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
// realpath reports the name as the disk stores it, which on APFS may be another case-folded spelling.
|
|
585
|
+
it('takes a spelling the disk folds together with git\'s for the same folder where the repository ignores case', async () => {
|
|
586
|
+
const check = fakes({ 'straße': folder }, { 'straße': path.join(root, 'STRASSE') });
|
|
587
|
+
expect(await firstRedirectedFolder(root, ['straße/file.txt'], { ignoreCase: true, ...check })).toBeNull();
|
|
588
|
+
expect(await firstRedirectedFolder(root, ['straße/file.txt'], check)).toBe('straße');
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// The names a patch may create, per platform. The Windows rules run here on every machine through
|
|
593
|
+
// the platform argument; each comes from Microsoft's naming documentation or from git's own check.
|
|
594
|
+
describe('nameNotAllowedHere', () => {
|
|
595
|
+
const refused = (name, platform = 'win32') => nameNotAllowedHere([name], [name], { platform });
|
|
596
|
+
|
|
597
|
+
it.each([
|
|
598
|
+
...['<', '>', ':', '"', '\\', '|', '?', '*', '\x01', '\t', '\x1f'].map((character) => [`a${character}b.txt`, 'reserved_character']),
|
|
599
|
+
['CON', 'device_name'], ['con', 'device_name'], ['Con.txt', 'device_name'], ['nul.tar.gz', 'device_name'],
|
|
600
|
+
['aux .txt', 'device_name'], ['PRN', 'device_name'], ['com1', 'device_name'], ['COM9.log', 'device_name'],
|
|
601
|
+
['lpt0', 'device_name'], ['LPT¹', 'device_name'], ['com³.x', 'device_name'], ['CONIN$', 'device_name'], ['conout$.txt', 'device_name'],
|
|
602
|
+
['name.', 'trailing_space_or_period'], ['name ', 'trailing_space_or_period'], ['folder./file.txt', 'trailing_space_or_period'],
|
|
603
|
+
])('refuses %j on Windows as %s, and lets it through on macOS and Linux', (name, rule) => {
|
|
604
|
+
expect(refused(name)).toMatchObject({ path: name, rule });
|
|
605
|
+
expect(refused(name, 'darwin')).toBeNull();
|
|
606
|
+
expect(refused(name, 'linux')).toBeNull();
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
// The control: ordinary names, and names that only look like the refused ones.
|
|
610
|
+
it.each([
|
|
611
|
+
'README.md', 'with spaces inside.txt', 'юнікод.txt', '.env', '..hidden', '[brackets] (and) {braces}.txt', 'a.b.c',
|
|
612
|
+
'console.log', 'auxiliary.txt', 'nullable', 'com0', 'com10', 'lpt10', 'CONIN', 'src/components/Button.tsx',
|
|
613
|
+
])('lets %j through on every platform', (name) => {
|
|
614
|
+
for (const platform of ['win32', 'darwin', 'linux']) expect(refused(name, platform)).toBeNull();
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// Case-blindness is the repository's, as git found it on the disk, not the operating system's.
|
|
618
|
+
const blind = { ignoreCase: true };
|
|
619
|
+
it('sees names that differ only in case, of files and of folders, where the repository ignores case', () => {
|
|
620
|
+
for (const platform of ['win32', 'darwin', 'linux']) {
|
|
621
|
+
expect(nameNotAllowedHere(['readme.md'], ['README.md', 'readme.md'], { platform, ...blind })).toMatchObject({ path: 'readme.md', rule: 'differs_only_in_case', other: 'README.md' });
|
|
622
|
+
expect(nameNotAllowedHere(['docs/new.md'], ['Docs/old.md', 'docs/new.md'], { platform, ...blind })).toMatchObject({ path: 'docs/new.md', rule: 'differs_only_in_case', other: 'Docs' });
|
|
623
|
+
// Where the repository does not ignore case, on any system, the two are two files.
|
|
624
|
+
expect(nameNotAllowedHere(['readme.md'], ['README.md', 'readme.md'], { platform })).toBeNull();
|
|
625
|
+
}
|
|
626
|
+
// A collision the user already has, between two names the patch does not create, is theirs.
|
|
627
|
+
expect(nameNotAllowedHere(['new.md'], ['A.md', 'a.md', 'new.md'], blind)).toBeNull();
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('lets a new file into a folder the user already has in two spellings', () => {
|
|
631
|
+
const all = ['Docs/old.md', 'docs/other.md', 'docs/new.md', 'Docs/also-new.md'];
|
|
632
|
+
expect(nameNotAllowedHere(['docs/new.md', 'Docs/also-new.md'], all, blind)).toBeNull();
|
|
633
|
+
// The control: with only one of the two spellings there before, the new one is the agent's.
|
|
634
|
+
expect(nameNotAllowedHere(['docs/new.md'], ['Docs/old.md', 'docs/new.md'], blind)).toMatchObject({ rule: 'differs_only_in_case', other: 'Docs' });
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('sees a rename that changes only the case, of a file and of a folder', () => {
|
|
638
|
+
expect(nameNotAllowedHere(['README.md'], ['README.md'], { ...blind, deleted: ['readme.md'] }))
|
|
639
|
+
.toMatchObject({ path: 'README.md', rule: 'case_only_rename', other: 'readme.md' });
|
|
640
|
+
expect(nameNotAllowedHere(['lib/a.js', 'lib/b.js'], ['lib/a.js', 'lib/b.js'], { ...blind, deleted: ['Lib/a.js', 'Lib/b.js'] }))
|
|
641
|
+
.toMatchObject({ path: 'lib/a.js', rule: 'case_only_rename', other: 'Lib' });
|
|
642
|
+
// The control: an ordinary rename is none of this check's business.
|
|
643
|
+
expect(nameNotAllowedHere(['lib/a.js'], ['lib/a.js'], { ...blind, deleted: ['src/a.js'] })).toBeNull();
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// APFS ignores the form wherever it ignores case, so ignoring case alone is enough for it too.
|
|
647
|
+
it('takes the two Unicode spellings of an accent for one name where the repository precomposes or ignores case', () => {
|
|
648
|
+
const composed = 'caf\u00e9.txt';
|
|
649
|
+
const decomposed = 'cafe\u0301.txt';
|
|
650
|
+
expect(nameNotAllowedHere([composed], [decomposed, composed], { precompose: true })).toMatchObject({ rule: 'differs_only_in_case' });
|
|
651
|
+
expect(nameNotAllowedHere([composed], [decomposed, composed], blind)).toMatchObject({ rule: 'differs_only_in_case' });
|
|
652
|
+
// The control: where the repository does neither, the two are two names.
|
|
653
|
+
expect(nameNotAllowedHere([composed], [decomposed, composed])).toBeNull();
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('refuses a name longer than 255 bytes and a path longer than 1024 bytes on every platform, and a long Windows path without core.longpaths', () => {
|
|
657
|
+
for (const platform of ['win32', 'darwin', 'linux']) {
|
|
658
|
+
expect(refused('n'.repeat(256), platform)).toMatchObject({ rule: 'name_too_long' });
|
|
659
|
+
expect(refused('\u00e9'.repeat(128), platform)).toMatchObject({ rule: 'name_too_long' });
|
|
660
|
+
expect(refused(`${'folder/'.repeat(147)}f`, platform)).toMatchObject({ rule: 'path_too_long' });
|
|
661
|
+
expect(refused('n'.repeat(255), platform)).toBeNull();
|
|
662
|
+
}
|
|
663
|
+
// 40 folders: 288 characters below the project, past Windows' 259 and far below 1024 bytes.
|
|
664
|
+
const deep = `${'folder/'.repeat(40)}file.txt`;
|
|
665
|
+
const top = 'C:\\Users\\someone\\project';
|
|
666
|
+
expect(nameNotAllowedHere([deep], [deep], { platform: 'win32', top })).toMatchObject({ rule: 'path_too_long' });
|
|
667
|
+
expect(nameNotAllowedHere([deep], [deep], { platform: 'win32', top, longPaths: true })).toBeNull();
|
|
668
|
+
expect(nameNotAllowedHere([deep], [deep], { platform: 'linux', top: '/home/someone/project' })).toBeNull();
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// Each pair is one name on APFS, checked on a real disk below. Lowering alone kept each apart.
|
|
672
|
+
const APFS_PAIRS = [['strasse.txt', 'straße.txt'], ['σ.txt', 'ς.txt'], ['fi.txt', 'fi.txt'], ['s.txt', 'ſ.txt'], ['β.txt', 'ϐ.txt'], ['ΑΣ.txt', 'ασ.txt'], ['ss.txt', 'ẞ.txt']];
|
|
673
|
+
|
|
674
|
+
it.each(APFS_PAIRS)('takes %j and %j for one name where the repository ignores case', (one, other) => {
|
|
675
|
+
expect(nameNotAllowedHere([other], [one, other], blind)).toMatchObject({ path: other, rule: 'differs_only_in_case', other: one });
|
|
676
|
+
expect(nameNotAllowedHere([other], [other], { ...blind, deleted: [one] })).toMatchObject({ path: other, rule: 'case_only_rename', other: one });
|
|
677
|
+
// Where case counts, the two are two names.
|
|
678
|
+
expect(nameNotAllowedHere([other], [one, other])).toBeNull();
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it.skipIf(process.platform !== 'darwin')('folds at least as wide as this Mac\'s disk, for each pair', () => {
|
|
682
|
+
const host = createTestHost();
|
|
683
|
+
const probe = path.join(host.root, 'fold-probe');
|
|
684
|
+
fs.mkdirSync(probe);
|
|
685
|
+
fs.writeFileSync(path.join(probe, 'Case.txt'), '');
|
|
686
|
+
// The control: this test means something only on a disk that ignores case.
|
|
687
|
+
expect(fs.existsSync(path.join(probe, 'case.txt')), 'the test folder is on a disk that ignores case').toBe(true);
|
|
688
|
+
for (const [one, other] of APFS_PAIRS) {
|
|
689
|
+
const folder = fs.mkdtempSync(path.join(probe, 'pair-'));
|
|
690
|
+
fs.writeFileSync(path.join(folder, one), '');
|
|
691
|
+
expect(fs.existsSync(path.join(folder, other)), `the disk takes ${one} and ${other} for one name`).toBe(true);
|
|
692
|
+
expect(nameNotAllowedHere([other], [one, other], blind), `the check takes ${one} and ${other} for one name`).not.toBeNull();
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it('refuses a path whose whole length, the project\'s folder included, passes 1023 bytes', () => {
|
|
697
|
+
const top = `/home/${'p'.repeat(60)}`;
|
|
698
|
+
// 66 + 1 + 956 = 1023 bytes, then one more.
|
|
699
|
+
const fits = `${'folder/'.repeat(136)}ffff`;
|
|
700
|
+
expect(Buffer.byteLength(path.posix.join(top, fits))).toBe(1023);
|
|
701
|
+
for (const platform of ['darwin', 'linux']) {
|
|
702
|
+
expect(nameNotAllowedHere([fits], [fits], { platform, top })).toBeNull();
|
|
703
|
+
expect(nameNotAllowedHere([`${fits}f`], [`${fits}f`], { platform, top })).toMatchObject({ rule: 'path_too_long' });
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it('holds Windows to 259 characters for a file and 247 for the folder it goes into, unless core.longpaths', () => {
|
|
708
|
+
const top = 'C:\\p';
|
|
709
|
+
// 4 + 1 + 254 = 259 characters for the file, in a folder of 4 + 1 + 242 = 247.
|
|
710
|
+
const folder = `${'d'.repeat(120)}/${'e'.repeat(121)}`;
|
|
711
|
+
const file = `${folder}/${'f'.repeat(11)}`;
|
|
712
|
+
expect(path.win32.join(top, file).length).toBe(259);
|
|
713
|
+
expect(path.win32.join(top, folder).length).toBe(247);
|
|
714
|
+
const check = (created, extra = {}) => nameNotAllowedHere([created], [created], { platform: 'win32', top, ...extra });
|
|
715
|
+
expect(check(file)).toBeNull();
|
|
716
|
+
expect(check(`${file}f`)).toMatchObject({ rule: 'path_too_long' });
|
|
717
|
+
// One more character in the folder, the file no longer.
|
|
718
|
+
const deeper = `${folder}e/${'f'.repeat(10)}`;
|
|
719
|
+
expect(path.win32.join(top, deeper).length).toBe(259);
|
|
720
|
+
expect(check(deeper)).toMatchObject({ rule: 'path_too_long' });
|
|
721
|
+
expect(check(`${file}f`, { longPaths: true })).toBeNull();
|
|
722
|
+
expect(check(deeper, { longPaths: true })).toBeNull();
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
// Measured before this: every folder of every path was kept as a string of its own, and one result
|
|
726
|
+
// with sixteen paths 2048 folders deep ran Node out of memory. The length rule stops such a path
|
|
727
|
+
// first; and a whole large tree with one new file keeps only the folders on the way of that file.
|
|
728
|
+
it('stays cheap for a large tree with a few new files', () => {
|
|
729
|
+
const all = [];
|
|
730
|
+
for (let file = 0; file < 200_000; file += 1) all.push(`packages/p${file % 500}/src/deep/er/file-${file}.ts`);
|
|
731
|
+
all.push('packages/p1/src/new.ts');
|
|
732
|
+
const started = process.hrtime.bigint();
|
|
733
|
+
expect(nameNotAllowedHere(['packages/p1/src/new.ts'], all, blind)).toBeNull();
|
|
734
|
+
const took = Number(process.hrtime.bigint() - started) / 1e6;
|
|
735
|
+
expect(took).toBeLessThan(2000);
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* A result made with git's plumbing: the space's start with `files` added at its top level, as
|
|
741
|
+
* `{ name: content }`, without any of them on a disk, so a name this computer cannot hold can still be
|
|
742
|
+
* in it. The tree is written raw, not through the index: Git for Windows refuses a name like
|
|
743
|
+
* `line\nbreak.txt` in the index, and the point is a tree that holds exactly such a name.
|
|
744
|
+
*/
|
|
745
|
+
const plumbedResult = async (host, files) => {
|
|
746
|
+
const bait = makeBait(host);
|
|
747
|
+
await host.codeIn().takeSnapshot({ repository: bait.repo, spaceId: SPACE_ID, mode: 'uncommitted' });
|
|
748
|
+
const commitOn = (parent, entries) => {
|
|
749
|
+
const listing = bait.g(['ls-tree', '-z', parent]).split('\0').filter(Boolean).map((line) => {
|
|
750
|
+
const [mode, type, id] = line.slice(0, line.indexOf('\t')).split(' ');
|
|
751
|
+
return { mode, type, id, name: line.slice(line.indexOf('\t') + 1) };
|
|
752
|
+
});
|
|
753
|
+
for (const [name, content] of Object.entries(entries)) {
|
|
754
|
+
expect(name.includes('/'), 'a plumbed name is one name at the top level').toBe(false);
|
|
755
|
+
// null takes a name out, which is how a rename is made.
|
|
756
|
+
if (content === null) {
|
|
757
|
+
listing.splice(listing.findIndex((entry) => entry.name === name), 1);
|
|
758
|
+
} else {
|
|
759
|
+
listing.push({ mode: '100644', type: 'blob', id: bait.g(['hash-object', '-w', '--stdin'], { input: content }).trim(), name });
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
// Git's own order: by bytes, a folder as if its name ended in a slash.
|
|
763
|
+
const key = (entry) => Buffer.from(entry.type === 'tree' ? `${entry.name}/` : entry.name);
|
|
764
|
+
listing.sort((a, b) => Buffer.compare(key(a), key(b)));
|
|
765
|
+
const raw = Buffer.concat(listing.flatMap((entry) => [
|
|
766
|
+
Buffer.from(`${entry.mode === '040000' ? '40000' : entry.mode} ${entry.name}\0`),
|
|
767
|
+
Buffer.from(entry.id, 'hex'),
|
|
768
|
+
]));
|
|
769
|
+
const tree = bait.g(['hash-object', '-t', 'tree', '--literally', '-w', '--stdin'], { input: raw }).trim();
|
|
770
|
+
return bait.g(['commit-tree', tree, '-p', parent, '-m', 'plumbed']).trim();
|
|
771
|
+
};
|
|
772
|
+
bait.g(['update-ref', RESULT, commitOn(START, files)]);
|
|
773
|
+
return { ...bait, commitOn };
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
describe('applyAsChanges and a name this computer cannot hold', () => {
|
|
777
|
+
it('refuses a name that differs only in case from one in the project, touches nothing, and keeps the route open', async () => {
|
|
778
|
+
const host = createTestHost();
|
|
779
|
+
const { repo, g, commitOn } = await plumbedResult(host, { 'readme.md': 'a second readme\n' });
|
|
780
|
+
// What git sets for a repository on a disk that ignores case; the check reads it from here.
|
|
781
|
+
g(['config', 'core.ignorecase', 'true']);
|
|
782
|
+
const codeOut = host.codeOut();
|
|
783
|
+
const before = hostState(repo);
|
|
784
|
+
const failure = await codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
785
|
+
expect(failure).toMatchObject({ code: 'name_not_allowed_here', details: { path: 'readme.md', rule: 'differs_only_in_case', other: 'README.md' } });
|
|
786
|
+
expect(failure.message).toBe('The agent made readme.md, which differs from README.md only in case, and this computer takes the two for one file, so nothing was changed. Have the agent rename it, then bring the work out again.');
|
|
787
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
788
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
789
|
+
// The agent renames it, the work comes out again, and it applies.
|
|
790
|
+
g(['update-ref', RESULT, commitOn(START, { 'readme-second.md': 'a second readme\n' })]);
|
|
791
|
+
expect(await codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
792
|
+
expect(fs.readFileSync(path.join(repo, 'readme-second.md'), 'utf8')).toBe('a second readme\n');
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it('redacts a control character in the name it reports', async () => {
|
|
796
|
+
const host = createTestHost();
|
|
797
|
+
const { repo } = await plumbedResult(host, { 'line\nbreak.txt': 'x\n' });
|
|
798
|
+
const codeOut = createCodeOut({ git: host.git, place: null, temporaryDirectory: host.root, platform: 'win32' });
|
|
799
|
+
const failure = await codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
800
|
+
expect(failure).toMatchObject({ code: 'name_not_allowed_here', details: { path: 'line?break.txt', rule: 'reserved_character' } });
|
|
801
|
+
expect(failure.message).toContain('The agent made line?break.txt, which has a name this computer cannot hold');
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
describe('applyAsChanges after round four', () => {
|
|
806
|
+
it('refuses a rename that changes only the case, touches nothing, and keeps the route open', async () => {
|
|
807
|
+
const host = createTestHost();
|
|
808
|
+
const { repo, g, commitOn } = await plumbedResult(host, { 'README.md': null, 'readme.md': 'hello\n' });
|
|
809
|
+
g(['config', 'core.ignorecase', 'true']);
|
|
810
|
+
const before = hostState(repo);
|
|
811
|
+
const failure = await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
812
|
+
expect(failure).toMatchObject({ code: 'case_only_rename', details: { path: 'readme.md', rule: 'case_only_rename', other: 'README.md' } });
|
|
813
|
+
expect(failure.message).toBe('The agent renamed README.md to readme.md, 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.');
|
|
814
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
815
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
816
|
+
// The branch holds it, and after a new name the work applies.
|
|
817
|
+
await host.codeOut().applyAsBranch({ repository: repo, spaceId: SPACE_ID, branch: 'with-the-rename' });
|
|
818
|
+
g(['update-ref', RESULT, commitOn(START, { 'README.md': null, 'read-me.md': 'hello\n' })]);
|
|
819
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied' });
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it('says so when a file the user keeps out of git is in the way, and still closes the route', async () => {
|
|
823
|
+
const host = createTestHost();
|
|
824
|
+
const { repo, g } = await hostResult(host, { edit: (scratch) => fs.writeFileSync(path.join(scratch, 'generated.txt'), 'from the space\n') });
|
|
825
|
+
fs.appendFileSync(path.join(repo, '.git', 'info', 'exclude'), 'generated.txt\n');
|
|
826
|
+
fs.writeFileSync(path.join(repo, 'generated.txt'), 'the user\'s own\n');
|
|
827
|
+
const failure = await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
828
|
+
expect(failure).toMatchObject({ code: 'changes_do_not_apply', details: { ignoredInTheWay: { count: 1, paths: ['generated.txt'] } } });
|
|
829
|
+
expect(failure.message).toMatch(/^Your project already has generated.txt, which git ignores here, and the work of the space adds a file of the same name, so nothing was changed\./);
|
|
830
|
+
expect(fs.readFileSync(path.join(repo, 'generated.txt'), 'utf8')).toBe('the user\'s own\n');
|
|
831
|
+
expect(g(['for-each-ref', CLOSED])).not.toBe('');
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
describe('an apply the host did not live to record', () => {
|
|
835
|
+
it('forgets an attempt that wrote nothing, and applies', async () => {
|
|
836
|
+
const host = createTestHost();
|
|
837
|
+
const { repo, g, result } = await hostResult(host, { edit: ordinaryEdit });
|
|
838
|
+
g(['update-ref', APPLYING, result]);
|
|
839
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied', remembered: true });
|
|
840
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(result);
|
|
841
|
+
expect(g(['for-each-ref', APPLYING, CLOSED])).toBe('');
|
|
842
|
+
expect(workingTreeAs(host, repo, result)).toBe(g(['rev-parse', `${result}^{tree}`]).trim());
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
it('says that part of the work is in the project when the attempt stopped halfway, and closes the route', async () => {
|
|
846
|
+
const host = createTestHost();
|
|
847
|
+
const { repo, g, result } = await hostResult(host, { edit: ordinaryEdit });
|
|
848
|
+
g(['update-ref', APPLYING, result]);
|
|
849
|
+
// What an interrupted apply leaves: one of its files, and not the others.
|
|
850
|
+
fs.writeFileSync(path.join(repo, 'added by the space.txt'), 'new\n');
|
|
851
|
+
const failure = await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
852
|
+
expect(failure).toMatchObject({ code: 'changes_partly_applied', details: { interrupted: true } });
|
|
853
|
+
expect(failure.message).toMatch(/^An earlier apply of the work of this space was interrupted, and part of it is in your project/);
|
|
854
|
+
expect(failure.message).toMatch(/applied as a branch/);
|
|
855
|
+
expect(g(['rev-parse', CLOSED]).trim()).toBe(result);
|
|
856
|
+
expect(g(['for-each-ref', APPLYING, APPLIED])).toBe('');
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it('finishes the record of an attempt that wrote everything, and has nothing left to apply', async () => {
|
|
860
|
+
const host = createTestHost();
|
|
861
|
+
const { repo, g, result, snapshot } = await hostResult(host, { edit: ordinaryEdit });
|
|
862
|
+
g(['update-ref', APPLYING, result]);
|
|
863
|
+
// What an apply that finished and was not recorded leaves: all of it.
|
|
864
|
+
const patch = path.join(host.root, 'finished.patch');
|
|
865
|
+
g(['diff-tree', '-r', '-p', '--binary', '--full-index', `--output=${forConfig(patch)}`, snapshot.start, result]);
|
|
866
|
+
g(['apply', '--binary', forConfig(patch)]);
|
|
867
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toEqual({ status: 'nothing_to_apply' });
|
|
868
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(result);
|
|
869
|
+
expect(g(['for-each-ref', APPLYING, CLOSED])).toBe('');
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
it('tells a caller where the space stands without changing anything', async () => {
|
|
874
|
+
const host = createTestHost();
|
|
875
|
+
const { repo, g, result } = await hostResult(host, { edit: ordinaryEdit });
|
|
876
|
+
const before = hostState(repo);
|
|
877
|
+
expect(await host.codeOut().describeApplyState({ repository: repo, spaceId: SPACE_ID })).toEqual({
|
|
878
|
+
changesRoute: 'open', result, lastApplied: null, newPaths: WIN ? 5 : 6, newPathsOverLimit: false, newPathsUndecided: false, interruptedApply: false,
|
|
879
|
+
});
|
|
880
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
881
|
+
await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID });
|
|
882
|
+
expect(await host.codeOut().describeApplyState({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ lastApplied: result, newPaths: 0 });
|
|
883
|
+
g(['update-ref', APPLYING, result]);
|
|
884
|
+
expect(await host.codeOut().describeApplyState({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ changesRoute: 'open', interruptedApply: true });
|
|
885
|
+
// An intent beside a closed route is not reported: nothing applies as changes there any more.
|
|
886
|
+
g(['update-ref', CLOSED, result]);
|
|
887
|
+
expect(await host.codeOut().describeApplyState({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ changesRoute: 'closed', interruptedApply: false });
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
// git reports a folder by the precomposed form of its name, and a macOS disk may hold the other form.
|
|
891
|
+
// That is the same folder, not a link.
|
|
892
|
+
it('does not take a folder whose name is stored in the other Unicode form for a link', async () => {
|
|
893
|
+
const check = {
|
|
894
|
+
lstat: async () => ({ isSymbolicLink: () => false, isDirectory: () => true }),
|
|
895
|
+
realpath: async (full) => full.normalize('NFD'),
|
|
896
|
+
};
|
|
897
|
+
const root = path.join(path.sep, 'work');
|
|
898
|
+
expect(await firstRedirectedFolder(root, ['café/new.txt'], { precompose: true, ...check })).toBeNull();
|
|
899
|
+
// The control: where the repository does not precompose, the two forms are two names.
|
|
900
|
+
expect(await firstRedirectedFolder(root, ['café/new.txt'], check)).toBe('café');
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it.skipIf(process.platform !== 'darwin')('applies into a tracked and an untracked folder whose names the disk stores decomposed', async () => {
|
|
904
|
+
const host = createTestHost();
|
|
905
|
+
const { repo, g, result } = await hostResult(host, {
|
|
906
|
+
prepare: ({ repo: bait, g: bg }) => {
|
|
907
|
+
fs.mkdirSync(path.join(bait, 'café'));
|
|
908
|
+
fs.writeFileSync(path.join(bait, 'café', 'menu.txt'), 'menu\n');
|
|
909
|
+
bg(['add', '--all']);
|
|
910
|
+
bg(['commit', '--quiet', '-m', 'a folder stored decomposed']);
|
|
911
|
+
},
|
|
912
|
+
edit: (scratch) => {
|
|
913
|
+
fs.writeFileSync(path.join(scratch, 'café', 'new.txt'), 'new\n');
|
|
914
|
+
fs.mkdirSync(path.join(scratch, 'notés'), { recursive: true });
|
|
915
|
+
fs.writeFileSync(path.join(scratch, 'notés', 'n.txt'), 'n\n');
|
|
916
|
+
},
|
|
917
|
+
});
|
|
918
|
+
// The untracked folder, on the host, in the decomposed form.
|
|
919
|
+
fs.mkdirSync(path.join(repo, 'notés'), { recursive: true });
|
|
920
|
+
expect(g(['config', '--bool', 'core.precomposeunicode']).trim()).toBe('true');
|
|
921
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied' });
|
|
922
|
+
expect(workingTreeAs(host, repo, result)).toBe(g(['rev-parse', `${result}^{tree}`]).trim());
|
|
923
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
924
|
+
});
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* A result made with git's plumbing on top of the space's start, for shapes a disk cannot easily
|
|
929
|
+
* hold or would take long to make. `files` maps a path to its content, each under a new top-level
|
|
930
|
+
* folder. `chains` adds `count` top-level folders `x0`, `x1` and so on, which all hold one and the
|
|
931
|
+
* same tree: `depth` folders `a`, one inside the other, with a file `f` at the bottom.
|
|
932
|
+
*/
|
|
933
|
+
const treeResult = async (host, { files = {}, chains = null } = {}) => {
|
|
934
|
+
const bait = makeBait(host);
|
|
935
|
+
await host.codeIn().takeSnapshot({ repository: bait.repo, spaceId: SPACE_ID, mode: 'uncommitted' });
|
|
936
|
+
const mktree = (entries) => bait.g(['mktree', '-z'], { input: entries.map((entry) => `${entry}\0`).join('') }).trim();
|
|
937
|
+
const blobOf = (content) => bait.g(['hash-object', '-w', '--stdin'], { input: content }).trim();
|
|
938
|
+
const build = (entries) => {
|
|
939
|
+
const here = [];
|
|
940
|
+
const below = new Map();
|
|
941
|
+
for (const [names, content] of entries) {
|
|
942
|
+
if (names.length === 1) {
|
|
943
|
+
here.push(`100644 blob ${blobOf(content)}\t${names[0]}`);
|
|
944
|
+
} else {
|
|
945
|
+
if (!below.has(names[0])) below.set(names[0], []);
|
|
946
|
+
below.get(names[0]).push([names.slice(1), content]);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
for (const [name, inner] of below) here.push(`040000 tree ${mktree(build(inner))}\t${name}`);
|
|
950
|
+
return here;
|
|
951
|
+
};
|
|
952
|
+
const top = bait.g(['ls-tree', '-z', START]).split('\0').filter(Boolean);
|
|
953
|
+
top.push(...build(Object.entries(files).map(([file, content]) => [file.split('/'), content])));
|
|
954
|
+
let tree = mktree(top);
|
|
955
|
+
if (chains) {
|
|
956
|
+
// Every chain in one `fast-import`: a `mktree` per folder cost 90 ms a process on Windows, 36 s for
|
|
957
|
+
// a chain 400 deep, which alone passed the test's time there.
|
|
958
|
+
const TEMPORARY = `refs/test-chains-${crypto.randomBytes(4).toString('hex')}`;
|
|
959
|
+
const lines = [`commit ${TEMPORARY}`, 'committer t <t@example.invalid> 0 +0000', 'data 6', 'chains', `from ${bait.g(['commit-tree', tree, '-m', 'base']).trim()}`];
|
|
960
|
+
const chain = Array(chains.depth).fill('a').join('/');
|
|
961
|
+
for (let index = 0; index < chains.count; index += 1) lines.push(`M 100644 inline x${index}/${chain}/f`, 'data 2', 'f');
|
|
962
|
+
bait.g(['fast-import', '--quiet'], { input: `${lines.join('\n')}\n\n` });
|
|
963
|
+
tree = bait.g(['rev-parse', `${TEMPORARY}^{tree}`]).trim();
|
|
964
|
+
bait.g(['update-ref', '-d', TEMPORARY]);
|
|
965
|
+
}
|
|
966
|
+
const commit = bait.g(['commit-tree', tree, '-p', START, '-m', 'plumbed']).trim();
|
|
967
|
+
bait.g(['update-ref', RESULT, commit]);
|
|
968
|
+
// In a pack, as a fetch leaves them: git reads a deep tree of loose objects many times slower.
|
|
969
|
+
bait.g(['repack', '-a', '-d', '-q']);
|
|
970
|
+
return { ...bait, result: commit };
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
/** A file of `lines` lines, `line 1` to `line <lines>`, with `changes` put in by line number. */
|
|
974
|
+
const numbered = (lines, changes = {}) => Array.from({ length: lines }, (_, index) => changes[index + 1] ?? `line ${index + 1}`).join('\n').concat('\n');
|
|
975
|
+
|
|
976
|
+
/** Applies the patch from the space's start to `result` to the working tree, as an apply the host did not live to record would have. */
|
|
977
|
+
const applyByHand = (host, g, snapshot, result) => {
|
|
978
|
+
const patch = path.join(host.root, `by-hand-${crypto.randomBytes(4).toString('hex')}.patch`);
|
|
979
|
+
g(['diff-tree', '-r', '-p', '--binary', '--full-index', `--output=${forConfig(patch)}`, snapshot.start, result]);
|
|
980
|
+
g(['apply', '--binary', forConfig(patch)]);
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
describe('applyAsChanges after round five', () => {
|
|
984
|
+
// Twenty thousand files five hundred folders deep, in one shared tree, passed every cap and ran the
|
|
985
|
+
// host out of memory in the checks before an apply. The folders count against a cap of their own.
|
|
986
|
+
it('refuses a change through more folders than the cap before anything is built, touches nothing, and the branch holds it', async () => {
|
|
987
|
+
const host = createTestHost();
|
|
988
|
+
const { repo, g, result } = await treeResult(host, { chains: { count: 30, depth: 5 } });
|
|
989
|
+
const before = hostState(repo);
|
|
990
|
+
const failure = await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID, maxChangedEntries: 100 }).catch((error) => error);
|
|
991
|
+
expect(failure).toMatchObject({ code: 'changes_too_large', details: { limit: 100 } });
|
|
992
|
+
expect(failure.message).toBe('The work of the space is too large to apply as uncommitted changes: it changes files in more than 100 folders. Nothing was changed. Apply it as a branch instead.');
|
|
993
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
994
|
+
expect(leftFolders(host)).toEqual([]);
|
|
995
|
+
expect(await host.codeOut().applyAsBranch({ repository: repo, spaceId: SPACE_ID, branch: 'deep' })).toEqual({ branch: 'deep', commit: result });
|
|
996
|
+
// The control: 30 paths and 180 folders, within caps of 200.
|
|
997
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID, maxChangedEntries: 200 })).toMatchObject({ status: 'applied', appliedPaths: 30 });
|
|
998
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
// Within the folder cap, but so deep that every look at a folder and every file written walks a long
|
|
1002
|
+
// way down: two chains of 100 folders are 200 folders and 10,300 steps.
|
|
1003
|
+
it('refuses a change whose folders lie too deep in all, and applies it with room for it', async () => {
|
|
1004
|
+
const host = createTestHost();
|
|
1005
|
+
const { repo, g } = await treeResult(host, { chains: { count: 2, depth: 99 } });
|
|
1006
|
+
// Paths two hundred characters long and more: on Windows the path rule would refuse them before the
|
|
1007
|
+
// steps are counted, and git there writes them only with core.longpaths. Elsewhere it changes nothing.
|
|
1008
|
+
g(['config', 'core.longpaths', 'true']);
|
|
1009
|
+
const before = hostState(repo);
|
|
1010
|
+
const failure = await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID, maxChangedEntries: 200 }).catch((error) => error);
|
|
1011
|
+
expect(failure).toMatchObject({ code: 'changes_too_large', details: { limit: 6400 } });
|
|
1012
|
+
expect(failure.message).toBe('The work of the space is too large to apply as uncommitted changes: it changes files so deep among so many folders that reaching them all takes more than 6400 steps from a folder into the next. Nothing was changed. Apply it as a branch instead.');
|
|
1013
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
1014
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID, maxChangedEntries: 400 })).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
it('refuses at the default cap the shape that ran the host out of memory, and does so quickly', async () => {
|
|
1018
|
+
const host = createTestHost();
|
|
1019
|
+
// 300 paths 401 folders deep, which are 120,300 folders, from 404 objects.
|
|
1020
|
+
const { repo } = await treeResult(host, { chains: { count: 300, depth: 400 } });
|
|
1021
|
+
const before = hostState(repo);
|
|
1022
|
+
const started = Date.now();
|
|
1023
|
+
await expect(host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID }))
|
|
1024
|
+
.rejects.toMatchObject({ code: 'changes_too_large', message: expect.stringContaining('Apply it as a branch instead.') });
|
|
1025
|
+
expect(Date.now() - started).toBeLessThan(20_000);
|
|
1026
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
// A tracked link that the space turned into a folder: git apply deletes the link first, so the
|
|
1030
|
+
// link is not in the way. A link of the user's that the patch does not delete still is.
|
|
1031
|
+
it.skipIf(WIN)('applies a folder where the patch deletes a tracked link, and keeps refusing a link it does not delete', async () => {
|
|
1032
|
+
const host = createTestHost();
|
|
1033
|
+
const { repo, g } = await hostResult(host, {
|
|
1034
|
+
prepare: ({ repo: bait, g: bg }) => {
|
|
1035
|
+
fs.symlinkSync('somewhere', path.join(bait, 'lib'));
|
|
1036
|
+
bg(['add', 'lib']);
|
|
1037
|
+
bg(['commit', '--quiet', '-m', 'a link', '--', 'lib']);
|
|
1038
|
+
},
|
|
1039
|
+
edit: (scratch) => {
|
|
1040
|
+
fs.rmSync(path.join(scratch, 'lib'));
|
|
1041
|
+
fs.mkdirSync(path.join(scratch, 'lib'));
|
|
1042
|
+
fs.writeFileSync(path.join(scratch, 'lib', 'own.txt'), 'own\n');
|
|
1043
|
+
},
|
|
1044
|
+
});
|
|
1045
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied' });
|
|
1046
|
+
expect(fs.lstatSync(path.join(repo, 'lib')).isDirectory()).toBe(true);
|
|
1047
|
+
expect(fs.readFileSync(path.join(repo, 'lib', 'own.txt'), 'utf8')).toBe('own\n');
|
|
1048
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
// Two applies of one space at once each passed the dry run, then the second found its patch no
|
|
1052
|
+
// longer fitting and closed the route.
|
|
1053
|
+
it('lets two applies of one space at once take turns: one applies, the other has nothing left', async () => {
|
|
1054
|
+
const host = createTestHost();
|
|
1055
|
+
const { repo, g, result } = await hostResult(host, { edit: ordinaryEdit });
|
|
1056
|
+
const outcomes = await Promise.all([
|
|
1057
|
+
host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID }),
|
|
1058
|
+
host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID }),
|
|
1059
|
+
host.codeOut().describeApplyState({ repository: repo, spaceId: SPACE_ID }),
|
|
1060
|
+
]);
|
|
1061
|
+
expect(outcomes.slice(0, 2).map((outcome) => outcome.status).sort()).toEqual(['applied', 'nothing_to_apply']);
|
|
1062
|
+
// The read takes its turn too, before or after the applies, and never sees one under way.
|
|
1063
|
+
expect(outcomes[2]).toMatchObject({ changesRoute: 'open', interruptedApply: false });
|
|
1064
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(result);
|
|
1065
|
+
expect(g(['for-each-ref', CLOSED, APPLYING])).toBe('');
|
|
1066
|
+
expect(workingTreeAs(host, repo, result)).toBe(g(['rev-parse', `${result}^{tree}`]).trim());
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
describe('an apply the host did not live to record, with an edit of the user\'s since', () => {
|
|
1070
|
+
const longResult = (host, extra = {}) => hostResult(host, {
|
|
1071
|
+
prepare: ({ repo, g }) => {
|
|
1072
|
+
fs.writeFileSync(path.join(repo, 'long.txt'), numbered(20));
|
|
1073
|
+
fs.writeFileSync(path.join(repo, 'other.txt'), 'other\n');
|
|
1074
|
+
g(['add', 'long.txt', 'other.txt']);
|
|
1075
|
+
g(['commit', '--quiet', '-m', 'long files of the user', '--', 'long.txt', 'other.txt']);
|
|
1076
|
+
},
|
|
1077
|
+
edit: (scratch) => {
|
|
1078
|
+
fs.writeFileSync(path.join(scratch, 'long.txt'), numbered(20, { 2: 'line 2 from the space' }));
|
|
1079
|
+
fs.writeFileSync(path.join(scratch, 'other.txt'), 'other from the space\n');
|
|
1080
|
+
},
|
|
1081
|
+
...extra,
|
|
1082
|
+
});
|
|
1083
|
+
const userEdit = (repo) => {
|
|
1084
|
+
const file = path.join(repo, 'long.txt');
|
|
1085
|
+
fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replace('line 18\n', 'line 18 of the user\n'));
|
|
1086
|
+
};
|
|
1087
|
+
|
|
1088
|
+
it('takes an attempt that wrote nothing for one never started, and applies beside the edit', async () => {
|
|
1089
|
+
const host = createTestHost();
|
|
1090
|
+
const { repo, g, result } = await longResult(host);
|
|
1091
|
+
g(['update-ref', APPLYING, result]);
|
|
1092
|
+
userEdit(repo);
|
|
1093
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied' });
|
|
1094
|
+
expect(fs.readFileSync(path.join(repo, 'long.txt'), 'utf8')).toBe(numbered(20, { 2: 'line 2 from the space', 18: 'line 18 of the user' }));
|
|
1095
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(result);
|
|
1096
|
+
expect(g(['for-each-ref', APPLYING, CLOSED])).toBe('');
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
it('takes an attempt that wrote everything for a finished one, with the edit beside it', async () => {
|
|
1100
|
+
const host = createTestHost();
|
|
1101
|
+
const { repo, g, result, snapshot } = await longResult(host);
|
|
1102
|
+
g(['update-ref', APPLYING, result]);
|
|
1103
|
+
applyByHand(host, g, snapshot, result);
|
|
1104
|
+
userEdit(repo);
|
|
1105
|
+
const before = hostState(repo);
|
|
1106
|
+
// Read first: it counts from what the working tree holds, and changes nothing.
|
|
1107
|
+
expect(await host.codeOut().describeApplyState({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ interruptedApply: true, lastApplied: null, newPaths: 0 });
|
|
1108
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
1109
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toEqual({ status: 'nothing_to_apply' });
|
|
1110
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(result);
|
|
1111
|
+
expect(g(['for-each-ref', APPLYING, CLOSED])).toBe('');
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
// `git apply` wrote one file and not the other. The file it wrote takes the same insertion a second
|
|
1115
|
+
// time, among lines that repeat, so the dry run alone would call the attempt never started.
|
|
1116
|
+
it('does not take a file the attempt wrote for one it never wrote, where its lines repeat', async () => {
|
|
1117
|
+
const host = createTestHost();
|
|
1118
|
+
const repeated = (withNew) => `${'x\n'.repeat(4)}${withNew ? 'new\n' : ''}${'x\n'.repeat(16)}`;
|
|
1119
|
+
const { repo, g, result } = await longResult(host, {
|
|
1120
|
+
prepare: ({ repo: bait, g: bg }) => {
|
|
1121
|
+
fs.writeFileSync(path.join(bait, 'repeated.txt'), repeated(false));
|
|
1122
|
+
fs.writeFileSync(path.join(bait, 'other.txt'), 'other\n');
|
|
1123
|
+
bg(['add', 'repeated.txt', 'other.txt']);
|
|
1124
|
+
bg(['commit', '--quiet', '-m', 'files of the user', '--', 'repeated.txt', 'other.txt']);
|
|
1125
|
+
},
|
|
1126
|
+
edit: (scratch) => {
|
|
1127
|
+
fs.writeFileSync(path.join(scratch, 'repeated.txt'), repeated(true));
|
|
1128
|
+
fs.writeFileSync(path.join(scratch, 'other.txt'), 'other from the space\n');
|
|
1129
|
+
},
|
|
1130
|
+
});
|
|
1131
|
+
g(['update-ref', APPLYING, result]);
|
|
1132
|
+
fs.writeFileSync(path.join(repo, 'repeated.txt'), repeated(true));
|
|
1133
|
+
const failure = await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
1134
|
+
expect(failure).toMatchObject({ code: 'changes_partly_applied', details: { interrupted: true, recorded: true } });
|
|
1135
|
+
expect(fs.readFileSync(path.join(repo, 'repeated.txt'), 'utf8')).toBe(repeated(true));
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
// The intent names an older result than the one there now: the route closes at what was being
|
|
1139
|
+
// applied, and the intent goes in the same transaction.
|
|
1140
|
+
it('closes the route at the interrupted result, not at a newer one, and drops the intent with it', async () => {
|
|
1141
|
+
const host = createTestHost();
|
|
1142
|
+
const { repo, g, result: interrupted } = await longResult(host);
|
|
1143
|
+
g(['update-ref', APPLYING, interrupted]);
|
|
1144
|
+
fs.writeFileSync(path.join(repo, 'other.txt'), 'other from the space\n');
|
|
1145
|
+
const newer = g(['commit-tree', `${interrupted}^{tree}`, '-p', interrupted, '-m', 'a later round']).trim();
|
|
1146
|
+
g(['update-ref', RESULT, newer]);
|
|
1147
|
+
const failure = await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
1148
|
+
expect(failure).toMatchObject({ code: 'changes_partly_applied', details: { interrupted: true, recorded: true } });
|
|
1149
|
+
expect(g(['rev-parse', CLOSED]).trim()).toBe(interrupted);
|
|
1150
|
+
expect(g(['for-each-ref', APPLYING])).toBe('');
|
|
1151
|
+
});
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
it('does not report an intent beside a closed route as an interrupted apply', async () => {
|
|
1155
|
+
const host = createTestHost();
|
|
1156
|
+
const { repo, g, result } = await hostResult(host, { edit: ordinaryEdit });
|
|
1157
|
+
g(['update-ref', APPLYING, result]);
|
|
1158
|
+
g(['update-ref', CLOSED, result]);
|
|
1159
|
+
expect(await host.codeOut().describeApplyState({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ changesRoute: 'closed', interruptedApply: false });
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
it('says what was exceeded when the list of changed paths is longer than the host reads, and describes that honestly', async () => {
|
|
1163
|
+
const host = createTestHost();
|
|
1164
|
+
const { repo, result } = await hostResult(host, { edit: ordinaryEdit });
|
|
1165
|
+
const listing = (args) => args.includes('diff-tree') && args.includes('-z') && !args.includes('-p');
|
|
1166
|
+
const git = {
|
|
1167
|
+
run: (directory, args, options) => host.git.run(directory, args, options),
|
|
1168
|
+
output: async (directory, args, options) => {
|
|
1169
|
+
if (listing(args)) throw new SpaceError('command_output_too_large', 'git diff-tree printed more than 67108864 bytes and was stopped');
|
|
1170
|
+
return host.git.output(directory, args, options);
|
|
1171
|
+
},
|
|
1172
|
+
};
|
|
1173
|
+
const codeOut = createCodeOut({ git, place: null, temporaryDirectory: host.root });
|
|
1174
|
+
const before = hostState(repo);
|
|
1175
|
+
const failure = await codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
1176
|
+
expect(failure).toMatchObject({ code: 'changes_too_large', details: { limit: 64 * 1024 * 1024 } });
|
|
1177
|
+
expect(failure.message).toBe('The work of the space is too large to apply as uncommitted changes: it changes so many paths that their list is longer than 67108864 bytes. Nothing was changed. Apply it as a branch instead.');
|
|
1178
|
+
expect(await codeOut.describeApplyState({ repository: repo, spaceId: SPACE_ID })).toEqual({
|
|
1179
|
+
changesRoute: 'open', result, lastApplied: null, newPaths: null, newPathsOverLimit: true, newPathsUndecided: false, interruptedApply: false,
|
|
1180
|
+
});
|
|
1181
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
// Windows limits a whole path to 259 characters unless the repository has `core.longpaths`, which
|
|
1185
|
+
// the apply reads from the repository itself.
|
|
1186
|
+
it('refuses a path too long for Windows, touches nothing, keeps the route open, and applies it once the repository has core.longpaths', async () => {
|
|
1187
|
+
const host = createTestHost();
|
|
1188
|
+
const deep = `${'folder-of-twenty-six-chars/'.repeat(10)}file.txt`;
|
|
1189
|
+
const { repo, g } = await treeResult(host, { files: { [deep]: 'deep\n' } });
|
|
1190
|
+
const codeOut = createCodeOut({ git: host.git, place: null, temporaryDirectory: host.root, platform: 'win32' });
|
|
1191
|
+
const before = hostState(repo);
|
|
1192
|
+
const failure = await codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
1193
|
+
expect(failure).toMatchObject({ code: 'name_not_allowed_here', details: { path: deep, rule: 'path_too_long' } });
|
|
1194
|
+
expect(failure.message).toBe(`The agent made ${deep}, which is a path too long for this computer, in your project's folder, so nothing was changed. Have the agent rename it, then bring the work out again.`);
|
|
1195
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
1196
|
+
g(['config', 'core.longpaths', 'true']);
|
|
1197
|
+
expect(await codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied' });
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
// Within the 1024 bytes a path may have below the project, and past what the host can open once the
|
|
1201
|
+
// project's own path is in front of it.
|
|
1202
|
+
it.skipIf(WIN)('refuses a path that is too long only with the project\'s folder in front of it, with a clear message', async () => {
|
|
1203
|
+
const host = createTestHost();
|
|
1204
|
+
const deep = `${`${'d'.repeat(99)}/`.repeat(9)}${'f'.repeat(90)}`;
|
|
1205
|
+
expect(Buffer.byteLength(deep)).toBeLessThanOrEqual(1023);
|
|
1206
|
+
const { repo, g } = await treeResult(host, { files: { [deep]: 'deep\n' } });
|
|
1207
|
+
expect(Buffer.byteLength(path.join(repo, deep))).toBeGreaterThan(1023);
|
|
1208
|
+
const before = hostState(repo);
|
|
1209
|
+
const failure = await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
1210
|
+
expect(failure).toMatchObject({ code: 'name_not_allowed_here', details: { rule: 'path_too_long' } });
|
|
1211
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
1212
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
1213
|
+
});
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Rounds of a space's work, made on the host: the bait with files of ten lines committed, `own`, the
|
|
1218
|
+
* space's start from code in, and `round(files)`, which writes `files` into a scratch checkout of the
|
|
1219
|
+
* work so far, null taking one out, commits, and makes that commit the space's result. `putBack`
|
|
1220
|
+
* does what a user who throws an apply away does: puts `paths` back in the working tree as `treeish`
|
|
1221
|
+
* has them, and removes the ones it does not have.
|
|
1222
|
+
*/
|
|
1223
|
+
const roundsResult = async (host, own = ['one.txt', 'two.txt', 'three.txt'], { prepare } = {}) => {
|
|
1224
|
+
const bait = makeBait(host);
|
|
1225
|
+
prepare?.(bait);
|
|
1226
|
+
for (const name of own) fs.writeFileSync(path.join(bait.repo, name), numbered(10));
|
|
1227
|
+
bait.g(['add', ...own]);
|
|
1228
|
+
bait.g(['commit', '--quiet', '-m', 'files of the user', '--', ...own]);
|
|
1229
|
+
const snapshot = await host.codeIn().takeSnapshot({ repository: bait.repo, spaceId: SPACE_ID, mode: 'uncommitted' });
|
|
1230
|
+
const scratch = path.join(host.root, `scratch-${crypto.randomBytes(4).toString('hex')}`);
|
|
1231
|
+
host.sh(host.root, ['init', '--quiet', scratch]);
|
|
1232
|
+
const s = (args) => host.sh(scratch, args);
|
|
1233
|
+
s(['fetch', '--quiet', '--no-write-fetch-head', bait.repo, `${START}:refs/heads/work`]);
|
|
1234
|
+
s(['checkout', '--quiet', 'work']);
|
|
1235
|
+
const round = (files) => {
|
|
1236
|
+
for (const [name, content] of Object.entries(files)) {
|
|
1237
|
+
if (content === null) {
|
|
1238
|
+
fs.rmSync(path.join(scratch, name));
|
|
1239
|
+
} else {
|
|
1240
|
+
fs.mkdirSync(path.dirname(path.join(scratch, name)), { recursive: true });
|
|
1241
|
+
fs.writeFileSync(path.join(scratch, name), content);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
s(['add', '--all']);
|
|
1245
|
+
s(['commit', '--quiet', '--allow-empty', '-m', 'a round of the agent']);
|
|
1246
|
+
bait.g(['fetch', '--quiet', '--no-write-fetch-head', scratch, `refs/heads/work:${RESULT}`]);
|
|
1247
|
+
return bait.g(['rev-parse', RESULT]).trim();
|
|
1248
|
+
};
|
|
1249
|
+
const putBack = (treeish, paths) => {
|
|
1250
|
+
for (const name of paths) {
|
|
1251
|
+
const file = path.join(bait.repo, name);
|
|
1252
|
+
if (bait.g(['ls-tree', '--name-only', treeish, '--', name]).trim() === '') fs.rmSync(file);
|
|
1253
|
+
else fs.writeFileSync(file, bait.g(['show', `${treeish}:${name}`]));
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
return { ...bait, start: snapshot.start, round, putBack };
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
// The maintainer's decision of 2026-09-25: the user applies, dislikes it, throws it all away, has the
|
|
1260
|
+
// agent redo it, and applies again. When the working tree holds no trace of the last apply, the next
|
|
1261
|
+
// one brings the whole work again from before it, and the route stays open.
|
|
1262
|
+
describe('applyAsChanges after the user threw an apply away', () => {
|
|
1263
|
+
const firstRound = { 'one.txt': numbered(10, { 3: 'line 3 from round one' }), 'added.txt': 'added in round one\n' };
|
|
1264
|
+
// The redo changes the line of one.txt that round one changed, so the ordinary patch does not apply
|
|
1265
|
+
// once round one is gone, and changes two.txt besides.
|
|
1266
|
+
const redo = { 'one.txt': numbered(10, { 3: 'line 3 redone' }), 'two.txt': numbered(10, { 5: 'line 5 redone' }) };
|
|
1267
|
+
const firstApplied = async (host) => {
|
|
1268
|
+
const work = await roundsResult(host);
|
|
1269
|
+
const first = work.round(firstRound);
|
|
1270
|
+
expect(await host.codeOut().applyAsChanges({ repository: work.repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
1271
|
+
expect(work.g(['rev-parse', APPLIED_FROM]).trim()).toBe(work.start);
|
|
1272
|
+
return { ...work, first };
|
|
1273
|
+
};
|
|
1274
|
+
const describeState = (host, repo) => host.codeOut().describeApplyState({ repository: repo, spaceId: SPACE_ID });
|
|
1275
|
+
|
|
1276
|
+
it('brings the whole work again after the last apply was thrown away, and keeps the route open', async () => {
|
|
1277
|
+
const host = createTestHost();
|
|
1278
|
+
const { repo, g, start, round, putBack } = await firstApplied(host);
|
|
1279
|
+
putBack(start, ['one.txt', 'added.txt']);
|
|
1280
|
+
const second = round(redo);
|
|
1281
|
+
// The ordinary patch, from what was applied, has two paths; the whole work has three.
|
|
1282
|
+
expect(g(['diff-tree', '-r', '--name-only', APPLIED, second]).trim().split('\n')).toHaveLength(2);
|
|
1283
|
+
const before = hostState(repo);
|
|
1284
|
+
expect(await describeState(host, repo)).toMatchObject({ changesRoute: 'open', newPaths: 3, interruptedApply: false });
|
|
1285
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
1286
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied', appliedPaths: 3, remembered: true });
|
|
1287
|
+
for (const name of ['one.txt', 'two.txt', 'added.txt']) expect(fs.readFileSync(path.join(repo, name), 'utf8')).toBe(g(['show', `${second}:${name}`]));
|
|
1288
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(second);
|
|
1289
|
+
expect(g(['rev-parse', APPLIED_FROM]).trim()).toBe(start);
|
|
1290
|
+
expect(g(['for-each-ref', CLOSED, APPLYING, APPLYING_FROM])).toBe('');
|
|
1291
|
+
expect(await describeState(host, repo)).toMatchObject({ changesRoute: 'open', lastApplied: second, newPaths: 0 });
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
// The redo also takes out the file round one added, and the user kept that file. The whole work
|
|
1295
|
+
// from the start would apply, since it does not touch that file, and leave it behind: it is not
|
|
1296
|
+
// taken, because part of the last apply is still there, and the message names both parts.
|
|
1297
|
+
it('refuses and closes the route when only part of the last apply was thrown away, naming what is gone and what is there', async () => {
|
|
1298
|
+
const host = createTestHost();
|
|
1299
|
+
const { repo, g, start, round, putBack } = await firstApplied(host);
|
|
1300
|
+
putBack(start, ['one.txt']);
|
|
1301
|
+
const second = round({ ...redo, 'added.txt': null });
|
|
1302
|
+
expect(g(['diff-tree', '-r', '--name-only', start, second]).trim().split('\n')).toEqual(['one.txt', 'two.txt']);
|
|
1303
|
+
expect(await describeState(host, repo)).toMatchObject({ newPaths: 3 });
|
|
1304
|
+
const before = hostState(repo);
|
|
1305
|
+
const failure = await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
1306
|
+
expect(failure).toMatchObject({
|
|
1307
|
+
code: 'changes_do_not_apply',
|
|
1308
|
+
details: { thrownAway: { count: 1, paths: ['one.txt'] }, stillThere: { count: 1, paths: ['added.txt'] } },
|
|
1309
|
+
});
|
|
1310
|
+
expect(failure.message).toBe('You threw away part of what was last applied from this space: one.txt is back as before, while added.txt is still there or changed since. An apply now would leave out what you threw away without a word, so nothing was changed. From now on this space is applied as a branch, which holds its whole work, the rounds you already applied included. 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.');
|
|
1311
|
+
expect(anyChangeSince(before, repo)).toEqual(CLOSURE);
|
|
1312
|
+
expect(g(['rev-parse', CLOSED]).trim()).toBe(second);
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
it('brings the whole work again beside an edit of the user\'s on a file the last apply did not touch', async () => {
|
|
1316
|
+
const host = createTestHost();
|
|
1317
|
+
const { repo, g, start, round, putBack } = await firstApplied(host);
|
|
1318
|
+
putBack(start, ['one.txt', 'added.txt']);
|
|
1319
|
+
fs.writeFileSync(path.join(repo, 'three.txt'), numbered(10, { 7: 'line 7 of the user' }));
|
|
1320
|
+
fs.writeFileSync(path.join(repo, 'the user\'s own.txt'), 'own\n');
|
|
1321
|
+
const second = round(redo);
|
|
1322
|
+
expect(await describeState(host, repo)).toMatchObject({ newPaths: 3 });
|
|
1323
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied', appliedPaths: 3 });
|
|
1324
|
+
expect(fs.readFileSync(path.join(repo, 'one.txt'), 'utf8')).toBe(g(['show', `${second}:one.txt`]));
|
|
1325
|
+
expect(fs.readFileSync(path.join(repo, 'three.txt'), 'utf8')).toBe(numbered(10, { 7: 'line 7 of the user' }));
|
|
1326
|
+
expect(fs.readFileSync(path.join(repo, 'the user\'s own.txt'), 'utf8')).toBe('own\n');
|
|
1327
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
// The user threw round one away and then edited another line of one of its files. The whole work
|
|
1331
|
+
// from the start would apply beside that edit; it is not taken, because that path is not back as it was.
|
|
1332
|
+
it('refuses when the user edited a path the last apply changed, even where the whole work would fit', async () => {
|
|
1333
|
+
const host = createTestHost();
|
|
1334
|
+
const { repo, g, start, round, putBack } = await firstApplied(host);
|
|
1335
|
+
putBack(start, ['one.txt', 'added.txt']);
|
|
1336
|
+
fs.writeFileSync(path.join(repo, 'one.txt'), numbered(10, { 9: 'line 9 of the user' }));
|
|
1337
|
+
const second = round(redo);
|
|
1338
|
+
expect(await describeState(host, repo)).toMatchObject({ newPaths: 2 });
|
|
1339
|
+
const before = hostState(repo);
|
|
1340
|
+
await expect(host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).rejects.toMatchObject({ code: 'changes_do_not_apply' });
|
|
1341
|
+
expect(anyChangeSince(before, repo)).toEqual(CLOSURE);
|
|
1342
|
+
expect(g(['rev-parse', CLOSED]).trim()).toBe(second);
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
it('brings only the last round again when only the last of two applies was thrown away', async () => {
|
|
1346
|
+
const host = createTestHost();
|
|
1347
|
+
const { repo, g, first, round, putBack } = await firstApplied(host);
|
|
1348
|
+
const second = round({ 'two.txt': numbered(10, { 5: 'line 5 from round two' }) });
|
|
1349
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
1350
|
+
expect(g(['rev-parse', APPLIED_FROM]).trim()).toBe(first);
|
|
1351
|
+
putBack(first, ['two.txt']);
|
|
1352
|
+
const third = round({ 'two.txt': numbered(10, { 5: 'line 5 redone' }), 'three.txt': numbered(10, { 1: 'line 1 redone' }) });
|
|
1353
|
+
expect(second).not.toBe(third);
|
|
1354
|
+
expect(await describeState(host, repo)).toMatchObject({ newPaths: 2 });
|
|
1355
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
1356
|
+
// Round one stays as it was applied, and round two comes again as redone.
|
|
1357
|
+
for (const name of ['one.txt', 'added.txt', 'two.txt', 'three.txt']) expect(fs.readFileSync(path.join(repo, name), 'utf8')).toBe(g(['show', `${third}:${name}`]));
|
|
1358
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(third);
|
|
1359
|
+
expect(g(['rev-parse', APPLIED_FROM]).trim()).toBe(first);
|
|
1360
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
it('takes the ordinary way when nothing was thrown away, and remembers where each apply came from', async () => {
|
|
1364
|
+
const host = createTestHost();
|
|
1365
|
+
const { repo, g, first, round } = await firstApplied(host);
|
|
1366
|
+
const second = round({ 'two.txt': numbered(10, { 5: 'line 5 from round two' }) });
|
|
1367
|
+
expect(await describeState(host, repo)).toMatchObject({ newPaths: 1 });
|
|
1368
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
1369
|
+
expect(fs.readFileSync(path.join(repo, 'one.txt'), 'utf8')).toBe(firstRound['one.txt']);
|
|
1370
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(second);
|
|
1371
|
+
expect(g(['rev-parse', APPLIED_FROM]).trim()).toBe(first);
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
// After a discard the whole work comes again even where the patch from the last apply would fit:
|
|
1375
|
+
// the redo touches only a file round one did not, and round one must not be lost with it.
|
|
1376
|
+
it('brings the whole work again after a discard even where the patch from the last apply would fit', async () => {
|
|
1377
|
+
const host = createTestHost();
|
|
1378
|
+
const { repo, g, start, round, putBack } = await firstApplied(host);
|
|
1379
|
+
putBack(start, ['one.txt', 'added.txt']);
|
|
1380
|
+
const second = round({ 'three.txt': numbered(10, { 1: 'line 1 from round two' }) });
|
|
1381
|
+
expect(g(['diff-tree', '-r', '--name-only', APPLIED, second]).trim()).toBe('three.txt');
|
|
1382
|
+
expect(await describeState(host, repo)).toMatchObject({ newPaths: 3 });
|
|
1383
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied', appliedPaths: 3 });
|
|
1384
|
+
for (const name of ['one.txt', 'added.txt', 'three.txt']) expect(fs.readFileSync(path.join(repo, name), 'utf8')).toBe(g(['show', `${second}:${name}`]));
|
|
1385
|
+
expect(g(['rev-parse', APPLIED_FROM]).trim()).toBe(start);
|
|
1386
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
// Thrown away and asked for again, with nothing new brought out: the same result applies again.
|
|
1390
|
+
it('applies the same result again after it was thrown away', async () => {
|
|
1391
|
+
const host = createTestHost();
|
|
1392
|
+
const { repo, g, start, first, putBack } = await firstApplied(host);
|
|
1393
|
+
putBack(start, ['one.txt', 'added.txt']);
|
|
1394
|
+
expect(await describeState(host, repo)).toMatchObject({ lastApplied: first, newPaths: 2 });
|
|
1395
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
1396
|
+
for (const name of ['one.txt', 'added.txt']) expect(fs.readFileSync(path.join(repo, name), 'utf8')).toBe(g(['show', `${first}:${name}`]));
|
|
1397
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(first);
|
|
1398
|
+
expect(g(['rev-parse', APPLIED_FROM]).trim()).toBe(start);
|
|
1399
|
+
// Kept this time: nothing more to apply.
|
|
1400
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toEqual({ status: 'nothing_to_apply' });
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
it('behaves as before in a repository that never recorded where the last apply came from', async () => {
|
|
1404
|
+
const host = createTestHost();
|
|
1405
|
+
const { repo, g, start, round, putBack } = await firstApplied(host);
|
|
1406
|
+
g(['update-ref', '-d', APPLIED_FROM]);
|
|
1407
|
+
putBack(start, ['one.txt', 'added.txt']);
|
|
1408
|
+
round(redo);
|
|
1409
|
+
expect(await describeState(host, repo)).toMatchObject({ newPaths: 2 });
|
|
1410
|
+
await expect(host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).rejects.toMatchObject({ code: 'changes_do_not_apply' });
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
it('holds the whole work to the caps, and refuses it as too large with the route open', async () => {
|
|
1414
|
+
const host = createTestHost();
|
|
1415
|
+
const { repo, g, start, round, putBack } = await firstApplied(host);
|
|
1416
|
+
putBack(start, ['one.txt', 'added.txt']);
|
|
1417
|
+
round(redo);
|
|
1418
|
+
// Two paths pass a cap of two; the three of the whole work do not.
|
|
1419
|
+
const before = hostState(repo);
|
|
1420
|
+
const failure = await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID, maxChangedEntries: 2 }).catch((error) => error);
|
|
1421
|
+
expect(failure).toMatchObject({ code: 'changes_too_large', details: { limit: 2 } });
|
|
1422
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
1423
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied', appliedPaths: 3 });
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
describe('an apply of the whole work the host did not live to record', () => {
|
|
1427
|
+
/** Code out whose `git apply`, the real one and not the dry run, ends the call as a host that died would: before it runs, or right after. */
|
|
1428
|
+
const dying = (host, { afterApply }) => createCodeOut({
|
|
1429
|
+
git: {
|
|
1430
|
+
output: (directory, args, options) => host.git.output(directory, args, options),
|
|
1431
|
+
run: async (directory, args, options) => {
|
|
1432
|
+
if (!(args.includes('apply') && !args.includes('--check'))) return host.git.run(directory, args, options);
|
|
1433
|
+
if (afterApply) await host.git.run(directory, args, options);
|
|
1434
|
+
throw new Error('the host went away');
|
|
1435
|
+
},
|
|
1436
|
+
},
|
|
1437
|
+
place: null,
|
|
1438
|
+
temporaryDirectory: host.root,
|
|
1439
|
+
});
|
|
1440
|
+
const discarded = async (host) => {
|
|
1441
|
+
const work = await firstApplied(host);
|
|
1442
|
+
work.putBack(work.start, ['one.txt', 'added.txt']);
|
|
1443
|
+
return { ...work, second: work.round(redo) };
|
|
1444
|
+
};
|
|
1445
|
+
|
|
1446
|
+
it('finishes the record, from where that apply came from, when it wrote everything', async () => {
|
|
1447
|
+
const host = createTestHost();
|
|
1448
|
+
const { repo, g, start, second } = await discarded(host);
|
|
1449
|
+
await expect(dying(host, { afterApply: true }).applyAsChanges({ repository: repo, spaceId: SPACE_ID })).rejects.toThrow();
|
|
1450
|
+
expect(g(['rev-parse', APPLYING]).trim()).toBe(second);
|
|
1451
|
+
expect(g(['rev-parse', APPLYING_FROM]).trim()).toBe(start);
|
|
1452
|
+
expect(await describeState(host, repo)).toMatchObject({ interruptedApply: true, newPaths: 0 });
|
|
1453
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toEqual({ status: 'nothing_to_apply' });
|
|
1454
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(second);
|
|
1455
|
+
expect(g(['rev-parse', APPLIED_FROM]).trim()).toBe(start);
|
|
1456
|
+
expect(g(['for-each-ref', APPLYING, APPLYING_FROM, CLOSED])).toBe('');
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
it('forgets it when it wrote nothing, and brings the whole work again', async () => {
|
|
1460
|
+
const host = createTestHost();
|
|
1461
|
+
const { repo, g, start, second } = await discarded(host);
|
|
1462
|
+
await expect(dying(host, { afterApply: false }).applyAsChanges({ repository: repo, spaceId: SPACE_ID })).rejects.toThrow();
|
|
1463
|
+
expect(g(['rev-parse', APPLYING_FROM]).trim()).toBe(start);
|
|
1464
|
+
expect(await describeState(host, repo)).toMatchObject({ interruptedApply: true, newPaths: 3 });
|
|
1465
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied', appliedPaths: 3 });
|
|
1466
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(second);
|
|
1467
|
+
expect(g(['rev-parse', APPLIED_FROM]).trim()).toBe(start);
|
|
1468
|
+
expect(g(['for-each-ref', APPLYING, APPLYING_FROM, CLOSED])).toBe('');
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
it('closes the route and drops both halves of the intent when it stopped halfway', async () => {
|
|
1472
|
+
const host = createTestHost();
|
|
1473
|
+
const { repo, g, second } = await discarded(host);
|
|
1474
|
+
await expect(dying(host, { afterApply: false }).applyAsChanges({ repository: repo, spaceId: SPACE_ID })).rejects.toThrow();
|
|
1475
|
+
fs.writeFileSync(path.join(repo, 'added.txt'), 'added in round one\n');
|
|
1476
|
+
const failure = await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
1477
|
+
expect(failure).toMatchObject({ code: 'changes_partly_applied', details: { interrupted: true, recorded: true } });
|
|
1478
|
+
expect(g(['rev-parse', CLOSED]).trim()).toBe(second);
|
|
1479
|
+
expect(g(['for-each-ref', APPLYING, APPLYING_FROM])).toBe('');
|
|
1480
|
+
});
|
|
1481
|
+
});
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
// Round seven: "thrown away" is decided from authoritative state, and a part thrown away is never
|
|
1485
|
+
// passed over in silence. See `decideBase` in code-out.js and decision 7 in DESIGN.md.
|
|
1486
|
+
describe('applyAsChanges after round six', () => {
|
|
1487
|
+
const firstRound = { 'one.txt': numbered(10, { 3: 'line 3 from round one' }), 'added.txt': 'added in round one\n' };
|
|
1488
|
+
const firstApplied = async (host, own) => {
|
|
1489
|
+
const work = await roundsResult(host, own);
|
|
1490
|
+
const first = work.round(firstRound);
|
|
1491
|
+
expect(await host.codeOut().applyAsChanges({ repository: work.repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
1492
|
+
return { ...work, first };
|
|
1493
|
+
};
|
|
1494
|
+
const describeState = (host, repo, codeOut = host.codeOut()) => codeOut.describeApplyState({ repository: repo, spaceId: SPACE_ID });
|
|
1495
|
+
const apply = (host, repo, codeOut = host.codeOut()) => codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID });
|
|
1496
|
+
const isRealApply = (args) => args.includes('apply') && !args.includes('--check');
|
|
1497
|
+
/** Code out whose real `git apply` ends the call as a host that died would: before it runs, or right after. */
|
|
1498
|
+
const dying = (host, { afterApply }) => createCodeOut({
|
|
1499
|
+
git: {
|
|
1500
|
+
output: (directory, args, options) => host.git.output(directory, args, options),
|
|
1501
|
+
run: async (directory, args, options) => {
|
|
1502
|
+
if (!isRealApply(args)) return host.git.run(directory, args, options);
|
|
1503
|
+
if (afterApply) await host.git.run(directory, args, options);
|
|
1504
|
+
throw new Error('the host went away');
|
|
1505
|
+
},
|
|
1506
|
+
},
|
|
1507
|
+
place: null,
|
|
1508
|
+
temporaryDirectory: host.root,
|
|
1509
|
+
});
|
|
1510
|
+
/** Code out whose git fails the calls `breaks` picks, as a git that cannot write would. */
|
|
1511
|
+
const breaking = (host, breaks) => createCodeOut({
|
|
1512
|
+
git: {
|
|
1513
|
+
run: (directory, args, options) => (breaks(args, options) ? Promise.resolve({ code: 128, stdout: '', stderr: 'fatal: broken\n' }) : host.git.run(directory, args, options)),
|
|
1514
|
+
output: async (directory, args, options) => {
|
|
1515
|
+
if (breaks(args, options)) throw new SpaceError('git_command_failed', 'git failed: broken', { exitCode: 128 });
|
|
1516
|
+
return host.git.output(directory, args, options);
|
|
1517
|
+
},
|
|
1518
|
+
},
|
|
1519
|
+
place: null,
|
|
1520
|
+
temporaryDirectory: host.root,
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
// The bot's probe: `git restore .` puts back what round one changed and leaves the file it added,
|
|
1524
|
+
// and the redo touches only another file, so the patch from the last apply fits. Applied, the part
|
|
1525
|
+
// the user threw away would have been gone without a word.
|
|
1526
|
+
it('refuses a part thrown away even where the redo touches only other files, and names both parts', async () => {
|
|
1527
|
+
const host = createTestHost();
|
|
1528
|
+
const { repo, g, round } = await firstApplied(host);
|
|
1529
|
+
g(['restore', '.']);
|
|
1530
|
+
const second = round({ 'three.txt': numbered(10, { 1: 'line 1 redone' }) });
|
|
1531
|
+
expect(g(['diff-tree', '-r', '--name-only', APPLIED, second]).trim()).toBe('three.txt');
|
|
1532
|
+
const before = hostState(repo);
|
|
1533
|
+
const failure = await apply(host, repo).catch((error) => error);
|
|
1534
|
+
expect(failure).toMatchObject({
|
|
1535
|
+
code: 'changes_do_not_apply',
|
|
1536
|
+
details: { thrownAway: { count: 1, paths: ['one.txt'] }, stillThere: { count: 1, paths: ['added.txt'] } },
|
|
1537
|
+
});
|
|
1538
|
+
expect(failure.message).toMatch(/^You threw away part of what was last applied from this space: one\.txt is back as before, while added\.txt is still there or changed since\./);
|
|
1539
|
+
expect(failure.message).toMatch(/the rounds you already applied included\. 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\.$/);
|
|
1540
|
+
expect(anyChangeSince(before, repo)).toEqual(CLOSURE);
|
|
1541
|
+
expect(g(['rev-parse', CLOSED]).trim()).toBe(second);
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
// An edit is not a throw-away: the path holds neither side, and the rest of the apply is there.
|
|
1545
|
+
it('takes an edit of the user\'s on a file of the last apply for kept, and brings only what is new', async () => {
|
|
1546
|
+
const host = createTestHost();
|
|
1547
|
+
const { repo, g, first, round } = await firstApplied(host);
|
|
1548
|
+
fs.writeFileSync(path.join(repo, 'one.txt'), numbered(10, { 3: 'line 3 from round one', 9: 'line 9 of the user' }));
|
|
1549
|
+
round({ 'three.txt': numbered(10, { 1: 'line 1 redone' }) });
|
|
1550
|
+
expect(await describeState(host, repo)).toMatchObject({ newPaths: 1 });
|
|
1551
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
1552
|
+
expect(fs.readFileSync(path.join(repo, 'one.txt'), 'utf8')).toBe(numbered(10, { 3: 'line 3 from round one', 9: 'line 9 of the user' }));
|
|
1553
|
+
expect(g(['rev-parse', APPLIED_FROM]).trim()).toBe(first);
|
|
1554
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
// Both of two applies thrown away: the patch from before the last one assumes round one, so it
|
|
1558
|
+
// either failed its dry run and closed, or applied without round one and said nothing.
|
|
1559
|
+
it('brings the whole work from the start when every apply was thrown away', async () => {
|
|
1560
|
+
const host = createTestHost();
|
|
1561
|
+
const { repo, g, start, first, round, putBack } = await firstApplied(host);
|
|
1562
|
+
round({ 'two.txt': numbered(10, { 5: 'line 5 from round two' }) });
|
|
1563
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
1564
|
+
expect(g(['rev-parse', APPLIED_FROM]).trim()).toBe(first);
|
|
1565
|
+
putBack(start, ['one.txt', 'added.txt', 'two.txt']);
|
|
1566
|
+
const third = round({ 'three.txt': numbered(10, { 1: 'line 1 from round three' }) });
|
|
1567
|
+
const before = hostState(repo);
|
|
1568
|
+
expect(await describeState(host, repo)).toMatchObject({ changesRoute: 'open', newPaths: 4 });
|
|
1569
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
1570
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 4 });
|
|
1571
|
+
for (const name of ['one.txt', 'added.txt', 'two.txt', 'three.txt']) expect(fs.readFileSync(path.join(repo, name), 'utf8')).toBe(g(['show', `${third}:${name}`]));
|
|
1572
|
+
expect(g(['rev-parse', APPLIED_FROM]).trim()).toBe(start);
|
|
1573
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
// The user committed round one: that is keeping it. Its files put back as before it afterwards hold
|
|
1577
|
+
// the side before the apply, but a commit of the user's since changed them, so each is the user's own
|
|
1578
|
+
// edit, and the whole work does not come again.
|
|
1579
|
+
it('takes the files of a last apply the user committed for kept, even where they look as before it', async () => {
|
|
1580
|
+
const host = createTestHost();
|
|
1581
|
+
const { repo, g, start, first, round, putBack } = await firstApplied(host);
|
|
1582
|
+
expect(g(['rev-parse', APPLIED_HEAD]).trim()).toBe(g(['rev-parse', 'HEAD']).trim());
|
|
1583
|
+
g(['add', 'one.txt', 'added.txt']);
|
|
1584
|
+
g(['commit', '--quiet', '-m', 'round one, kept', '--', 'one.txt', 'added.txt']);
|
|
1585
|
+
putBack(start, ['one.txt', 'added.txt']);
|
|
1586
|
+
const second = round({ 'three.txt': numbered(10, { 1: 'line 1 from round two' }) });
|
|
1587
|
+
expect(await describeState(host, repo)).toMatchObject({ newPaths: 1 });
|
|
1588
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
1589
|
+
expect(fs.readFileSync(path.join(repo, 'one.txt'), 'utf8')).toBe(g(['show', `${start}:one.txt`]));
|
|
1590
|
+
expect(fs.existsSync(path.join(repo, 'added.txt'))).toBe(false);
|
|
1591
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(second);
|
|
1592
|
+
expect(g(['rev-parse', APPLIED_FROM]).trim()).toBe(first);
|
|
1593
|
+
expect(g(['rev-parse', APPLIED_HEAD]).trim()).toBe(g(['rev-parse', 'HEAD']).trim());
|
|
1594
|
+
});
|
|
1595
|
+
|
|
1596
|
+
// Committed, then one file of it deleted: a commit of the user's added that file, so the deletion is
|
|
1597
|
+
// the user's own edit, not a throw-away, and nothing is refused for it; only the dry run decides.
|
|
1598
|
+
it('takes a file deleted after the user committed the last apply for an edit, and lets the dry run decide', async () => {
|
|
1599
|
+
const host = createTestHost();
|
|
1600
|
+
const { repo, g, round } = await firstApplied(host);
|
|
1601
|
+
g(['add', 'one.txt', 'added.txt']);
|
|
1602
|
+
g(['commit', '--quiet', '-m', 'round one, kept', '--', 'one.txt', 'added.txt']);
|
|
1603
|
+
fs.rmSync(path.join(repo, 'added.txt'));
|
|
1604
|
+
const second = round({ 'three.txt': numbered(10, { 1: 'line 1 from round two' }) });
|
|
1605
|
+
expect(await describeState(host, repo)).toMatchObject({ newPaths: 1 });
|
|
1606
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
1607
|
+
expect(fs.existsSync(path.join(repo, 'added.txt'))).toBe(false);
|
|
1608
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(second);
|
|
1609
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
1610
|
+
// A redo that touches the deleted file does not fit, the ordinary refusal.
|
|
1611
|
+
const third = round({ 'added.txt': 'added and redone\n' });
|
|
1612
|
+
const before = hostState(repo);
|
|
1613
|
+
const failure = await apply(host, repo).catch((error) => error);
|
|
1614
|
+
expect(failure).toMatchObject({ code: 'changes_do_not_apply', details: { exitCode: 1 } });
|
|
1615
|
+
expect(failure.message).toMatch(/^The work of the space does not fit your project any more: your project changed since its work was last applied here/);
|
|
1616
|
+
expect(anyChangeSince(before, repo)).toEqual(CLOSURE);
|
|
1617
|
+
expect(g(['rev-parse', CLOSED]).trim()).toBe(third);
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
// A last apply that changed only a gitlink leaves nothing the comparison can see, which is no
|
|
1621
|
+
// evidence that it is gone: the whole work came again on every later apply.
|
|
1622
|
+
it('takes a last apply that changed only a gitlink for kept', async () => {
|
|
1623
|
+
const host = createTestHost();
|
|
1624
|
+
const { repo, g, start } = await roundsResult(host);
|
|
1625
|
+
const commitWith = (base, entries) => {
|
|
1626
|
+
const names = entries.map(([, name]) => name);
|
|
1627
|
+
const kept = g(['ls-tree', '-z', base]).split('\0').filter(Boolean).filter((entry) => !names.includes(entry.slice(entry.indexOf('\t') + 1)));
|
|
1628
|
+
const tree = g(['mktree', '-z'], { input: [...kept, ...entries.map(([entry]) => entry)].map((entry) => `${entry}\0`).join('') }).trim();
|
|
1629
|
+
const commit = g(['commit-tree', tree, '-p', base, '-m', 'plumbed']).trim();
|
|
1630
|
+
g(['update-ref', RESULT, commit]);
|
|
1631
|
+
return commit;
|
|
1632
|
+
};
|
|
1633
|
+
const first = commitWith(start, [[`160000 commit ${start}\tnested`, 'nested']]);
|
|
1634
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
1635
|
+
const second = commitWith(first, [[`160000 commit ${g(['rev-parse', 'HEAD']).trim()}\tnested`, 'nested']]);
|
|
1636
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
1637
|
+
expect(g(['rev-parse', APPLIED_FROM]).trim()).toBe(first);
|
|
1638
|
+
const three = g(['hash-object', '-w', '--stdin'], { input: numbered(10, { 1: 'line 1 from round three' }) }).trim();
|
|
1639
|
+
commitWith(second, [[`100644 blob ${three}\tthree.txt`, 'three.txt']]);
|
|
1640
|
+
expect(await describeState(host, repo)).toMatchObject({ newPaths: 1 });
|
|
1641
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
1642
|
+
expect(g(['rev-parse', APPLIED_FROM]).trim()).toBe(second);
|
|
1643
|
+
});
|
|
1644
|
+
|
|
1645
|
+
// A dry run reads every file of the patch through the user's filters, and one that does not end
|
|
1646
|
+
// within the deadline of the apply failed every later call the same way.
|
|
1647
|
+
it('answers a dry run that does not end in time, and closes the route', async () => {
|
|
1648
|
+
const host = createTestHost();
|
|
1649
|
+
const { repo, g, round } = await roundsResult(host);
|
|
1650
|
+
const result = round({ 'two.txt': numbered(10, { 5: 'line 5 from the space' }) });
|
|
1651
|
+
const before = hostState(repo);
|
|
1652
|
+
const stalled = createCodeOut({
|
|
1653
|
+
git: {
|
|
1654
|
+
output: (directory, args, options) => host.git.output(directory, args, options),
|
|
1655
|
+
run: (directory, args, options) => (args.includes('--check')
|
|
1656
|
+
? Promise.reject(new SpaceError('command_timeout', 'git apply did not finish within 600000 ms and was stopped'))
|
|
1657
|
+
: host.git.run(directory, args, options)),
|
|
1658
|
+
},
|
|
1659
|
+
place: null,
|
|
1660
|
+
temporaryDirectory: host.root,
|
|
1661
|
+
});
|
|
1662
|
+
const stuck = await apply(host, repo, stalled).catch((error) => error);
|
|
1663
|
+
expect(stuck).toMatchObject({ code: 'changes_undecided', details: { limitMs: 600_000 } });
|
|
1664
|
+
expect(stuck.message).toMatch(/^Checking whether the work of the space fits your project took longer than 600 seconds, so nothing was changed\./);
|
|
1665
|
+
expect(g(['rev-parse', CLOSED]).trim()).toBe(result);
|
|
1666
|
+
expect(anyChangeSince(before, repo)).toEqual(CLOSURE);
|
|
1667
|
+
});
|
|
1668
|
+
|
|
1669
|
+
// The agent brought an attribute that makes git store x.txt with plain line endings, then a round
|
|
1670
|
+
// that only turns x.txt to CRLF. Through the user's attributes that change cannot be seen: a kept
|
|
1671
|
+
// apply read as thrown away, and the same result applied again forever.
|
|
1672
|
+
it.skipIf(WIN)('takes a kept apply the comparison cannot see for kept, alone and beside one it can', async () => {
|
|
1673
|
+
const host = createTestHost();
|
|
1674
|
+
const { repo, g, round } = await roundsResult(host);
|
|
1675
|
+
round({ '.gitattributes': 'x.txt text eol=lf\n', 'x.txt': 'a\nb\n' });
|
|
1676
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
1677
|
+
const plumbed = (base, files) => {
|
|
1678
|
+
const entries = g(['ls-tree', base]).split('\n').filter(Boolean).filter((entry) => !Object.keys(files).some((name) => entry.endsWith(`\t${name}`)));
|
|
1679
|
+
for (const [name, content] of Object.entries(files)) entries.push(`100644 blob ${g(['hash-object', '-w', '--no-filters', '--stdin'], { input: content }).trim()}\t${name}`);
|
|
1680
|
+
const commit = g(['commit-tree', g(['mktree'], { input: `${entries.join('\n')}\n` }).trim(), '-p', base, '-m', 'plumbed']).trim();
|
|
1681
|
+
g(['update-ref', RESULT, commit]);
|
|
1682
|
+
return commit;
|
|
1683
|
+
};
|
|
1684
|
+
const crlf = plumbed(g(['rev-parse', RESULT]).trim(), { 'x.txt': 'a\r\nb\r\n' });
|
|
1685
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
1686
|
+
expect(await describeState(host, repo)).toMatchObject({ newPaths: 0 });
|
|
1687
|
+
expect(await apply(host, repo)).toEqual({ status: 'nothing_to_apply' });
|
|
1688
|
+
// Beside a change it can see, the one it cannot is not taken for a part thrown away.
|
|
1689
|
+
plumbed(crlf, { 'x.txt': 'c\r\nd\r\n', 'two.txt': numbered(10, { 5: 'line 5 from round three' }) });
|
|
1690
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
1691
|
+
plumbed(g(['rev-parse', RESULT]).trim(), { 'three.txt': numbered(10, { 1: 'line 1 from round four' }) });
|
|
1692
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
1693
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1696
|
+
// The host died before its `git apply` of round two, and then the user threw round one away. The
|
|
1697
|
+
// attempt wrote nothing; read against round one alone it looked like a part and closed the route.
|
|
1698
|
+
it('takes an attempt that wrote nothing for one never started after the user threw the apply before it away', async () => {
|
|
1699
|
+
const host = createTestHost();
|
|
1700
|
+
const { repo, g, start, round, putBack } = await firstApplied(host);
|
|
1701
|
+
const second = round({ 'one.txt': numbered(10, { 3: 'line 3 from round one', 4: 'line 4 from round two' }) });
|
|
1702
|
+
await expect(apply(host, repo, dying(host, { afterApply: false }))).rejects.toThrow();
|
|
1703
|
+
expect(g(['rev-parse', APPLYING]).trim()).toBe(second);
|
|
1704
|
+
putBack(start, ['one.txt', 'added.txt']);
|
|
1705
|
+
const before = hostState(repo);
|
|
1706
|
+
expect(await describeState(host, repo)).toMatchObject({ interruptedApply: true, newPaths: 2 });
|
|
1707
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
1708
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
1709
|
+
for (const name of ['one.txt', 'added.txt']) expect(fs.readFileSync(path.join(repo, name), 'utf8')).toBe(g(['show', `${second}:${name}`]));
|
|
1710
|
+
expect(g(['rev-parse', APPLIED_FROM]).trim()).toBe(start);
|
|
1711
|
+
expect(g(['for-each-ref', CLOSED, APPLYING, APPLYING_FROM, APPLYING_HEAD])).toBe('');
|
|
1712
|
+
});
|
|
1713
|
+
|
|
1714
|
+
// Where the interrupted apply is read as a part, the apply closes before it chooses a base, so the
|
|
1715
|
+
// read does not count from a base the apply never takes.
|
|
1716
|
+
it('counts from the last apply beside an interrupted apply that left a part, even after a throw-away', async () => {
|
|
1717
|
+
const host = createTestHost();
|
|
1718
|
+
const { repo, g, start, first, round, putBack } = await firstApplied(host);
|
|
1719
|
+
const second = round({ 'one.txt': numbered(10, { 3: 'line 3 redone' }), 'two.txt': numbered(10, { 5: 'line 5 redone' }) });
|
|
1720
|
+
g(['update-ref', APPLYING, second]);
|
|
1721
|
+
g(['update-ref', APPLYING_FROM, first]);
|
|
1722
|
+
putBack(start, ['one.txt', 'added.txt']);
|
|
1723
|
+
fs.writeFileSync(path.join(repo, 'two.txt'), g(['show', `${second}:two.txt`]));
|
|
1724
|
+
const before = hostState(repo);
|
|
1725
|
+
expect(await describeState(host, repo)).toMatchObject({ interruptedApply: true, newPaths: 2 });
|
|
1726
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
1727
|
+
await expect(apply(host, repo)).rejects.toMatchObject({ code: 'changes_partly_applied', details: { interrupted: true, recorded: true } });
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
// The verifier's: a route closed stays closed, whatever the user does to the working tree after.
|
|
1731
|
+
it('keeps a closed route closed once the user throws the last apply away', async () => {
|
|
1732
|
+
const host = createTestHost();
|
|
1733
|
+
const { repo, g, start, round, putBack } = await firstApplied(host);
|
|
1734
|
+
putBack(start, ['one.txt']);
|
|
1735
|
+
round({ 'one.txt': numbered(10, { 3: 'line 3 redone' }) });
|
|
1736
|
+
fs.writeFileSync(path.join(repo, 'one.txt'), numbered(10, { 3: 'line 3 of the user' }));
|
|
1737
|
+
await expect(apply(host, repo)).rejects.toMatchObject({ code: 'changes_do_not_apply' });
|
|
1738
|
+
putBack(start, ['one.txt', 'added.txt']);
|
|
1739
|
+
const before = hostState(repo);
|
|
1740
|
+
expect(await describeState(host, repo)).toMatchObject({ changesRoute: 'closed' });
|
|
1741
|
+
await expect(apply(host, repo)).rejects.toMatchObject({ code: 'changes_route_closed' });
|
|
1742
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
// The verifier's: reading where the space stands writes nothing, beside an intent that wrote nothing.
|
|
1746
|
+
it('changes nothing when it reads an intent that wrote nothing', async () => {
|
|
1747
|
+
const host = createTestHost();
|
|
1748
|
+
const { repo, g, round } = await firstApplied(host);
|
|
1749
|
+
round({ 'two.txt': numbered(10, { 5: 'line 5 from round two' }) });
|
|
1750
|
+
await expect(apply(host, repo, dying(host, { afterApply: false }))).rejects.toThrow();
|
|
1751
|
+
const before = hostState(repo);
|
|
1752
|
+
const refs = g(['for-each-ref']);
|
|
1753
|
+
expect(await describeState(host, repo)).toMatchObject({ interruptedApply: true, newPaths: 1 });
|
|
1754
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
1755
|
+
expect(g(['for-each-ref'])).toBe(refs);
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
// The verifier's: a failed write of the intent leaves nothing behind, and the next apply brings the
|
|
1759
|
+
// whole work once.
|
|
1760
|
+
it('brings the whole work once after the intent of a whole-work apply could not be written', async () => {
|
|
1761
|
+
const host = createTestHost();
|
|
1762
|
+
const { repo, g, start, round, putBack } = await firstApplied(host);
|
|
1763
|
+
putBack(start, ['one.txt', 'added.txt']);
|
|
1764
|
+
round({ 'one.txt': numbered(10, { 3: 'line 3 redone' }), 'two.txt': numbered(10, { 5: 'line 5 redone' }) });
|
|
1765
|
+
const before = hostState(repo);
|
|
1766
|
+
await expect(apply(host, repo, breaking(host, (args, options) => args.includes('update-ref') && String(options?.stdin ?? '').startsWith(`update ${APPLYING} `)))).rejects.toBeTruthy();
|
|
1767
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
1768
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 3 });
|
|
1769
|
+
expect(fs.readFileSync(path.join(repo, 'one.txt'), 'utf8')).toBe(numbered(10, { 3: 'line 3 redone' }));
|
|
1770
|
+
expect(g(['rev-parse', APPLIED_FROM]).trim()).toBe(start);
|
|
1771
|
+
});
|
|
1772
|
+
|
|
1773
|
+
// The verifier's: the host died after its `git apply`, and the first call to sort that out could not
|
|
1774
|
+
// write its record. The next one does, and nothing is applied twice.
|
|
1775
|
+
it('records a whole-work apply the host did not live to record, after a first attempt to sort it out failed', async () => {
|
|
1776
|
+
const host = createTestHost();
|
|
1777
|
+
const { repo, g, start, round, putBack } = await firstApplied(host);
|
|
1778
|
+
putBack(start, ['one.txt', 'added.txt']);
|
|
1779
|
+
const second = round({ 'one.txt': numbered(10, { 3: 'line 3 redone' }), 'two.txt': numbered(10, { 5: 'line 5 redone' }) });
|
|
1780
|
+
await expect(apply(host, repo, dying(host, { afterApply: true }))).rejects.toThrow();
|
|
1781
|
+
const after = hostState(repo);
|
|
1782
|
+
await expect(apply(host, repo, breaking(host, (args) => args.includes('update-ref') && args.some((arg) => String(arg).includes('sorted out'))))).rejects.toBeTruthy();
|
|
1783
|
+
expect(g(['rev-parse', APPLYING]).trim()).toBe(second);
|
|
1784
|
+
expect(await apply(host, repo)).toEqual({ status: 'nothing_to_apply' });
|
|
1785
|
+
expect(unexpectedChanges(after, hostState(repo), { spaceIds: [SPACE_ID], codeOut: true })).toEqual([]);
|
|
1786
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(second);
|
|
1787
|
+
expect(g(['rev-parse', APPLIED_FROM]).trim()).toBe(start);
|
|
1788
|
+
expect(g(['rev-parse', APPLIED_HEAD]).trim()).toBe(g(['rev-parse', 'HEAD']).trim());
|
|
1789
|
+
expect(g(['for-each-ref', APPLYING, APPLYING_FROM, APPLYING_HEAD, CLOSED])).toBe('');
|
|
1790
|
+
});
|
|
1791
|
+
|
|
1792
|
+
// The verifier's: the patch deletes a file F and makes a folder F, and the user's F is a link to
|
|
1793
|
+
// another place. Only a link the patch deletes as a link, mode 120000, is passed over.
|
|
1794
|
+
it.skipIf(WIN)('refuses a folder where the patch deletes a file that is a link on the user\'s disk', async () => {
|
|
1795
|
+
const host = createTestHost();
|
|
1796
|
+
const outside = path.join(host.root, `outside-${crypto.randomBytes(4).toString('hex')}`);
|
|
1797
|
+
fs.mkdirSync(outside);
|
|
1798
|
+
const { repo, g } = await hostResult(host, {
|
|
1799
|
+
prepare: ({ repo: bait, g: bg }) => {
|
|
1800
|
+
fs.writeFileSync(path.join(bait, 'F'), 'a file\n');
|
|
1801
|
+
bg(['add', 'F']);
|
|
1802
|
+
bg(['commit', '--quiet', '-m', 'a file', '--', 'F']);
|
|
1803
|
+
},
|
|
1804
|
+
edit: (scratch) => {
|
|
1805
|
+
fs.rmSync(path.join(scratch, 'F'));
|
|
1806
|
+
fs.mkdirSync(path.join(scratch, 'F'));
|
|
1807
|
+
fs.writeFileSync(path.join(scratch, 'F', 'evil.txt'), 'evil\n');
|
|
1808
|
+
},
|
|
1809
|
+
});
|
|
1810
|
+
fs.rmSync(path.join(repo, 'F'));
|
|
1811
|
+
fs.symlinkSync(outside, path.join(repo, 'F'));
|
|
1812
|
+
await expect(apply(host, repo)).rejects.toMatchObject({ code: 'changes_blocked_by_link', details: { path: 'F' } });
|
|
1813
|
+
expect(fs.readdirSync(outside)).toEqual([]);
|
|
1814
|
+
expect(g(['for-each-ref', CLOSED])).not.toBe('');
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
// 30 paths through 180 folders: a cap of 180 takes them, 179 does not.
|
|
1818
|
+
it('holds the folder cap to its exact number', async () => {
|
|
1819
|
+
const host = createTestHost();
|
|
1820
|
+
const { repo } = await treeResult(host, { chains: { count: 30, depth: 5 } });
|
|
1821
|
+
const before = hostState(repo);
|
|
1822
|
+
await expect(host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID, maxChangedEntries: 179 }))
|
|
1823
|
+
.rejects.toMatchObject({ code: 'changes_too_large', message: expect.stringContaining('in more than 179 folders') });
|
|
1824
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
1825
|
+
expect(await host.codeOut().applyAsChanges({ repository: repo, spaceId: SPACE_ID, maxChangedEntries: 180 })).toMatchObject({ status: 'applied', appliedPaths: 30 });
|
|
1826
|
+
});
|
|
1827
|
+
|
|
1828
|
+
// Two linked worktrees of one repository share the refs of a space, so an apply from one and a read
|
|
1829
|
+
// from the other take turns: the read never sees the apply under way as interrupted.
|
|
1830
|
+
it('makes calls from two linked worktrees of one repository take turns', async () => {
|
|
1831
|
+
const host = createTestHost();
|
|
1832
|
+
const { repo, g, result } = await hostResult(host, { edit: ordinaryEdit });
|
|
1833
|
+
const other = path.join(host.root, `linked-${crypto.randomBytes(4).toString('hex')}`);
|
|
1834
|
+
g(['worktree', 'add', '--quiet', '--detach', other]);
|
|
1835
|
+
let reached;
|
|
1836
|
+
const atApply = new Promise((resolve) => { reached = resolve; });
|
|
1837
|
+
const slow = createCodeOut({
|
|
1838
|
+
git: {
|
|
1839
|
+
output: (directory, args, options) => host.git.output(directory, args, options),
|
|
1840
|
+
run: async (directory, args, options) => {
|
|
1841
|
+
if (isRealApply(args)) {
|
|
1842
|
+
reached();
|
|
1843
|
+
await new Promise((resolve) => { setTimeout(resolve, 1500); });
|
|
1844
|
+
}
|
|
1845
|
+
return host.git.run(directory, args, options);
|
|
1846
|
+
},
|
|
1847
|
+
},
|
|
1848
|
+
place: null,
|
|
1849
|
+
temporaryDirectory: host.root,
|
|
1850
|
+
});
|
|
1851
|
+
const applying = apply(host, repo, slow);
|
|
1852
|
+
await atApply;
|
|
1853
|
+
const seen = await describeState(host, other);
|
|
1854
|
+
expect(seen).toMatchObject({ interruptedApply: false, lastApplied: result });
|
|
1855
|
+
expect(await applying).toMatchObject({ status: 'applied', remembered: true });
|
|
1856
|
+
expect(g(['for-each-ref', APPLYING, CLOSED])).toBe('');
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
// A dry run that fails prints a line or two for every path that does not fit. Past four megabytes
|
|
1860
|
+
// the host's output cap stopped it, and every later call failed the same way.
|
|
1861
|
+
it.skipIf(WIN)('answers a dry run that fails on thousands of long paths, however much git prints', { timeout: 120_000 }, async () => {
|
|
1862
|
+
const host = createTestHost();
|
|
1863
|
+
const bait = makeBait(host);
|
|
1864
|
+
await host.codeIn().takeSnapshot({ repository: bait.repo, spaceId: SPACE_ID, mode: 'uncommitted' });
|
|
1865
|
+
const { repo, g } = bait;
|
|
1866
|
+
const count = 8000;
|
|
1867
|
+
const folder = ['d'.repeat(200), 'e'.repeat(200)];
|
|
1868
|
+
const name = (index) => `${'f'.repeat(150)}-${index}.txt`;
|
|
1869
|
+
const blobId = g(['hash-object', '-w', '--stdin'], { input: 'from the space\n' }).trim();
|
|
1870
|
+
const mktree = (entries) => g(['mktree', '-z'], { input: entries.map((entry) => `${entry}\0`).join('') }).trim();
|
|
1871
|
+
let tree = mktree(Array.from({ length: count }, (_, index) => `100644 blob ${blobId}\t${name(index)}`));
|
|
1872
|
+
tree = mktree([`040000 tree ${tree}\t${folder[1]}`]);
|
|
1873
|
+
const top = g(['ls-tree', '-z', START]).split('\0').filter(Boolean);
|
|
1874
|
+
const result = g(['commit-tree', mktree([...top, `040000 tree ${tree}\t${folder[0]}`]), '-p', START, '-m', 'plumbed']).trim();
|
|
1875
|
+
g(['update-ref', RESULT, result]);
|
|
1876
|
+
// The user already has every one of those files.
|
|
1877
|
+
fs.mkdirSync(path.join(repo, ...folder), { recursive: true });
|
|
1878
|
+
for (let index = 0; index < count; index += 1) fs.writeFileSync(path.join(repo, ...folder, name(index)), 'the user\'s\n');
|
|
1879
|
+
const failure = await apply(host, repo).catch((error) => error);
|
|
1880
|
+
expect(failure).toMatchObject({ code: 'changes_do_not_apply' });
|
|
1881
|
+
expect(failure.message).toMatch(/already exists in working directory$/);
|
|
1882
|
+
expect(g(['rev-parse', CLOSED]).trim()).toBe(result);
|
|
1883
|
+
// The same at the recovery of an intent, whose dry runs print as much.
|
|
1884
|
+
g(['update-ref', '-d', CLOSED]);
|
|
1885
|
+
g(['update-ref', APPLYING, result]);
|
|
1886
|
+
await expect(apply(host, repo)).rejects.toMatchObject({ code: 'changes_partly_applied', details: { interrupted: true, recorded: true } });
|
|
1887
|
+
expect(g(['for-each-ref', APPLYING])).toBe('');
|
|
1888
|
+
});
|
|
1889
|
+
|
|
1890
|
+
// Reading the working tree back is bounded: past its time the call answers, closes the route, and
|
|
1891
|
+
// every later call answers at once, instead of every call failing the same way.
|
|
1892
|
+
it('answers a read-back that does not end in time, and closes the route with the intent', async () => {
|
|
1893
|
+
const host = createTestHost();
|
|
1894
|
+
const { repo, g, round } = await firstApplied(host);
|
|
1895
|
+
round({ 'two.txt': numbered(10, { 5: 'line 5 from round two' }) });
|
|
1896
|
+
const hurried = host.codeOut(null, { readBackTimeoutMs: 1 });
|
|
1897
|
+
const before = hostState(repo);
|
|
1898
|
+
expect(await describeState(host, repo, hurried)).toMatchObject({ newPaths: null, newPathsUndecided: true, newPathsOverLimit: false });
|
|
1899
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
1900
|
+
const failure = await apply(host, repo, hurried).catch((error) => error);
|
|
1901
|
+
expect(failure).toMatchObject({ code: 'changes_undecided', details: { recorded: true } });
|
|
1902
|
+
expect(failure.message).toMatch(/^Reading your project back took longer than 0 seconds/);
|
|
1903
|
+
expect(anyChangeSince(before, repo)).toEqual(CLOSURE);
|
|
1904
|
+
await expect(apply(host, repo)).rejects.toMatchObject({ code: 'changes_route_closed' });
|
|
1905
|
+
// With an intent, the closure and its removal are one.
|
|
1906
|
+
const other = createTestHost();
|
|
1907
|
+
const again = await firstApplied(other);
|
|
1908
|
+
again.round({ 'two.txt': numbered(10, { 5: 'line 5 from round two' }) });
|
|
1909
|
+
await expect(apply(other, again.repo, dying(other, { afterApply: false }))).rejects.toThrow();
|
|
1910
|
+
await expect(apply(other, again.repo, other.codeOut(null, { readBackTimeoutMs: 1 }))).rejects.toMatchObject({ code: 'changes_undecided', details: { recorded: true } });
|
|
1911
|
+
expect(again.g(['for-each-ref', APPLYING, APPLYING_FROM, APPLYING_HEAD])).toBe('');
|
|
1912
|
+
expect(again.g(['for-each-ref', CLOSED])).not.toBe('');
|
|
1913
|
+
});
|
|
1914
|
+
|
|
1915
|
+
// The same with a clean filter slow enough, a second a file, which is what made every call fail. The
|
|
1916
|
+
// user edited every file of the last apply, so none is byte for byte a side of it, and each is read
|
|
1917
|
+
// back through the filter.
|
|
1918
|
+
it.skipIf(WIN)('answers in time where the user\'s clean filter makes every file slow to read', { timeout: 60_000 }, async () => {
|
|
1919
|
+
const host = createTestHost();
|
|
1920
|
+
const own = ['a.txt', 'b.txt', 'c.txt', 'd.txt', 'e.txt', 'one.txt'];
|
|
1921
|
+
const { repo, g, round } = await firstApplied(host, own);
|
|
1922
|
+
round(Object.fromEntries(own.slice(0, 5).map((name) => [name, numbered(10, { 2: 'from round two' })])));
|
|
1923
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 5 });
|
|
1924
|
+
for (const name of own.slice(0, 5)) fs.writeFileSync(path.join(repo, name), numbered(10, { 2: 'from round two', 9: 'line 9 of the user' }));
|
|
1925
|
+
round({ 'one.txt': numbered(10, { 3: 'line 3 from round one', 7: 'line 7 from round three' }) });
|
|
1926
|
+
g(['config', 'filter.slow.clean', 'sleep 1; cat']);
|
|
1927
|
+
fs.writeFileSync(path.join(repo, '.git', 'info', 'attributes'), '*.txt filter=slow\n');
|
|
1928
|
+
const started = Date.now();
|
|
1929
|
+
await expect(apply(host, repo, host.codeOut(null, { readBackTimeoutMs: 2000 }))).rejects.toMatchObject({ code: 'changes_undecided' });
|
|
1930
|
+
expect(Date.now() - started).toBeLessThan(15_000);
|
|
1931
|
+
expect(g(['for-each-ref', CLOSED])).not.toBe('');
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1934
|
+
// Where an earlier apply is still there, one look at one path answers whether any apply was thrown
|
|
1935
|
+
// away; only the last apply's own paths are all read, for a part thrown away.
|
|
1936
|
+
it('stops at the first path that shows an earlier apply is still there', async () => {
|
|
1937
|
+
const host = createTestHost();
|
|
1938
|
+
const own = [...Array.from({ length: 40 }, (_, index) => `n${String(index).padStart(2, '0')}.txt`), 'three.txt'];
|
|
1939
|
+
const work = await roundsResult(host, own);
|
|
1940
|
+
work.round(Object.fromEntries(own.slice(0, 40).map((name) => [name, numbered(10, { 3: 'from round one' })])));
|
|
1941
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 40 });
|
|
1942
|
+
work.round({ 'three.txt': numbered(10, { 1: 'from round two' }) });
|
|
1943
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
1944
|
+
work.round({ 'n00.txt': numbered(10, { 3: 'from round one', 5: 'from round three' }) });
|
|
1945
|
+
let compared = 0;
|
|
1946
|
+
const counting = host.codeOut(null, { files: { ...fs.promises, open: (...args) => { compared += 1; return fs.promises.open(...args); } } });
|
|
1947
|
+
expect(await apply(host, work.repo, counting)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
1948
|
+
// One path of the 41 the applies changed, and the one path of the last apply.
|
|
1949
|
+
expect(compared).toBe(2);
|
|
1950
|
+
});
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
// Round eight: each path of the last apply is judged by what the working tree holds and by the
|
|
1954
|
+
// user's commits since, and reading the tree back reads the user's files as they are, with exactly
|
|
1955
|
+
// the attributes the working tree holds now. See `whatBecameOf` and `sidesHeld` in code-out.js.
|
|
1956
|
+
describe('applyAsChanges after round seven', () => {
|
|
1957
|
+
const apply = (host, repo, codeOut = host.codeOut()) => codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID });
|
|
1958
|
+
const tryApply = (host, repo, codeOut) => apply(host, repo, codeOut).catch((error) => error);
|
|
1959
|
+
const describeState = (host, repo, codeOut = host.codeOut()) => codeOut.describeApplyState({ repository: repo, spaceId: SPACE_ID });
|
|
1960
|
+
const isRealApply = (args) => args.includes('apply') && !args.includes('--check');
|
|
1961
|
+
/** Code out whose real `git apply` ends the call as a host that died would: before it runs, or right after. */
|
|
1962
|
+
const dying = (host, { afterApply }) => createCodeOut({
|
|
1963
|
+
git: {
|
|
1964
|
+
output: (directory, args, options) => host.git.output(directory, args, options),
|
|
1965
|
+
run: async (directory, args, options) => {
|
|
1966
|
+
if (!isRealApply(args)) return host.git.run(directory, args, options);
|
|
1967
|
+
if (afterApply) await host.git.run(directory, args, options);
|
|
1968
|
+
throw new Error('the host went away');
|
|
1969
|
+
},
|
|
1970
|
+
},
|
|
1971
|
+
place: null,
|
|
1972
|
+
temporaryDirectory: host.root,
|
|
1973
|
+
});
|
|
1974
|
+
/** Whether the working tree holds each of `names` byte for byte as `treeish` has it, or not at all where it has none. */
|
|
1975
|
+
const holdsAll = ({ repo, g }, treeish, names) => names.every((name) => {
|
|
1976
|
+
const file = path.join(repo, name);
|
|
1977
|
+
if (g(['ls-tree', '--name-only', treeish, '--', name]).trim() === '') return !fs.existsSync(file);
|
|
1978
|
+
return fs.existsSync(file) && fs.readFileSync(file, 'utf8') === g(['show', `${treeish}:${name}`]);
|
|
1979
|
+
});
|
|
1980
|
+
const closed = ({ g }) => g(['for-each-ref', CLOSED]) !== '';
|
|
1981
|
+
|
|
1982
|
+
// The ordinary reviewer's matrix, with the user's actions done by git. Round one changes a.txt and
|
|
1983
|
+
// adds feature.ts.
|
|
1984
|
+
describe('what the user did after an apply', () => {
|
|
1985
|
+
const roundOne = { 'a.txt': numbered(10, { 5: 'line 5 from round one' }), 'feature.ts': 'export const x = 1;\n' };
|
|
1986
|
+
const b = { 'b.txt': numbered(10, { 3: 'line 3 from round two' }) };
|
|
1987
|
+
const c = { 'c.txt': numbered(10, { 7: 'line 7 from round three' }) };
|
|
1988
|
+
// The agent redid the work from its start: round one is gone from the result.
|
|
1989
|
+
const redoneFromScratch = { 'a.txt': numbered(10), 'feature.ts': null, ...b };
|
|
1990
|
+
const applied = async (host) => {
|
|
1991
|
+
const work = await roundsResult(host, ['a.txt', 'b.txt', 'c.txt']);
|
|
1992
|
+
work.round(roundOne);
|
|
1993
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
1994
|
+
return work;
|
|
1995
|
+
};
|
|
1996
|
+
const throwAway = (work) => work.putBack(work.start, ['a.txt', 'feature.ts']);
|
|
1997
|
+
const commit = ({ g }, names, message = 'kept') => {
|
|
1998
|
+
g(['add', ...names]);
|
|
1999
|
+
g(['commit', '--quiet', '-m', message, '--', ...names]);
|
|
2000
|
+
};
|
|
2001
|
+
// What a pull of a colleague's work does to HEAD and the working tree.
|
|
2002
|
+
const pull = (work) => {
|
|
2003
|
+
fs.writeFileSync(path.join(work.repo, 'CHANGES.md'), 'from a colleague\n');
|
|
2004
|
+
commit(work, ['CHANGES.md'], 'a colleague');
|
|
2005
|
+
};
|
|
2006
|
+
const sideBranch = ({ repo, g }) => {
|
|
2007
|
+
g(['switch', '--quiet', '-c', 'side']);
|
|
2008
|
+
fs.writeFileSync(path.join(repo, 'side.txt'), 'side\n');
|
|
2009
|
+
commit({ g }, ['side.txt'], 'side');
|
|
2010
|
+
g(['switch', '--quiet', '-']);
|
|
2011
|
+
};
|
|
2012
|
+
|
|
2013
|
+
it('E1: kept, then a pull: only what is new', async () => {
|
|
2014
|
+
const host = createTestHost();
|
|
2015
|
+
const work = await applied(host);
|
|
2016
|
+
pull(work);
|
|
2017
|
+
const second = work.round(b);
|
|
2018
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2019
|
+
expect(holdsAll(work, second, ['a.txt', 'b.txt', 'feature.ts'])).toBe(true);
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
it('E2: thrown away, then a pull, and the agent went on: the whole work', async () => {
|
|
2023
|
+
const host = createTestHost();
|
|
2024
|
+
const work = await applied(host);
|
|
2025
|
+
throwAway(work);
|
|
2026
|
+
pull(work);
|
|
2027
|
+
const second = work.round(b);
|
|
2028
|
+
expect(await describeState(host, work.repo)).toMatchObject({ newPaths: 3 });
|
|
2029
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 3 });
|
|
2030
|
+
expect(holdsAll(work, second, ['a.txt', 'b.txt', 'feature.ts'])).toBe(true);
|
|
2031
|
+
expect(closed(work)).toBe(false);
|
|
2032
|
+
});
|
|
2033
|
+
|
|
2034
|
+
it('E3: thrown away, then a pull, and the agent redid it: the redo', async () => {
|
|
2035
|
+
const host = createTestHost();
|
|
2036
|
+
const work = await applied(host);
|
|
2037
|
+
throwAway(work);
|
|
2038
|
+
pull(work);
|
|
2039
|
+
const second = work.round(redoneFromScratch);
|
|
2040
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2041
|
+
expect(holdsAll(work, second, ['a.txt', 'b.txt', 'feature.ts'])).toBe(true);
|
|
2042
|
+
expect(closed(work)).toBe(false);
|
|
2043
|
+
});
|
|
2044
|
+
|
|
2045
|
+
it('B3: thrown away, then a switch to another branch, and the agent went on: the whole work', async () => {
|
|
2046
|
+
const host = createTestHost();
|
|
2047
|
+
const work = await applied(host);
|
|
2048
|
+
sideBranch(work);
|
|
2049
|
+
throwAway(work);
|
|
2050
|
+
work.g(['switch', '--quiet', 'side']);
|
|
2051
|
+
const second = work.round(b);
|
|
2052
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 3 });
|
|
2053
|
+
expect(holdsAll(work, second, ['a.txt', 'b.txt', 'feature.ts'])).toBe(true);
|
|
2054
|
+
expect(closed(work)).toBe(false);
|
|
2055
|
+
});
|
|
2056
|
+
|
|
2057
|
+
it('B4: thrown away, then a switch to another branch, and the agent redid it: the redo', async () => {
|
|
2058
|
+
const host = createTestHost();
|
|
2059
|
+
const work = await applied(host);
|
|
2060
|
+
sideBranch(work);
|
|
2061
|
+
throwAway(work);
|
|
2062
|
+
work.g(['switch', '--quiet', 'side']);
|
|
2063
|
+
const second = work.round(redoneFromScratch);
|
|
2064
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2065
|
+
expect(holdsAll(work, second, ['a.txt', 'b.txt', 'feature.ts'])).toBe(true);
|
|
2066
|
+
expect(closed(work)).toBe(false);
|
|
2067
|
+
});
|
|
2068
|
+
|
|
2069
|
+
it('C1: stashed and popped again: kept, only what is new', async () => {
|
|
2070
|
+
const host = createTestHost();
|
|
2071
|
+
const work = await applied(host);
|
|
2072
|
+
work.g(['stash', 'push', '--quiet', '--include-untracked', '--', 'a.txt', 'feature.ts']);
|
|
2073
|
+
work.g(['stash', 'pop', '--quiet']);
|
|
2074
|
+
const second = work.round(b);
|
|
2075
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2076
|
+
expect(holdsAll(work, second, ['a.txt', 'b.txt', 'feature.ts'])).toBe(true);
|
|
2077
|
+
});
|
|
2078
|
+
|
|
2079
|
+
it('C2: stashed, and the next round applied while it is stashed: the whole work', async () => {
|
|
2080
|
+
const host = createTestHost();
|
|
2081
|
+
const work = await applied(host);
|
|
2082
|
+
work.g(['stash', 'push', '--quiet', '--include-untracked', '--', 'a.txt', 'feature.ts']);
|
|
2083
|
+
const second = work.round(b);
|
|
2084
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 3 });
|
|
2085
|
+
expect(holdsAll(work, second, ['a.txt', 'b.txt', 'feature.ts'])).toBe(true);
|
|
2086
|
+
});
|
|
2087
|
+
|
|
2088
|
+
it('C3: stashed and dropped: the whole work', async () => {
|
|
2089
|
+
const host = createTestHost();
|
|
2090
|
+
const work = await applied(host);
|
|
2091
|
+
work.g(['stash', 'push', '--quiet', '--include-untracked', '--', 'a.txt', 'feature.ts']);
|
|
2092
|
+
work.g(['stash', 'drop', '--quiet']);
|
|
2093
|
+
const second = work.round(b);
|
|
2094
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 3 });
|
|
2095
|
+
expect(holdsAll(work, second, ['a.txt', 'b.txt', 'feature.ts'])).toBe(true);
|
|
2096
|
+
});
|
|
2097
|
+
|
|
2098
|
+
it('D1: committed, the next round amended into that commit: only what is new', async () => {
|
|
2099
|
+
const host = createTestHost();
|
|
2100
|
+
const work = await applied(host);
|
|
2101
|
+
commit(work, ['a.txt', 'feature.ts']);
|
|
2102
|
+
work.round(b);
|
|
2103
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2104
|
+
work.g(['commit', '--quiet', '--amend', '--no-edit', '--', 'b.txt']);
|
|
2105
|
+
const third = work.round(c);
|
|
2106
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2107
|
+
expect(holdsAll(work, third, ['a.txt', 'b.txt', 'c.txt', 'feature.ts'])).toBe(true);
|
|
2108
|
+
});
|
|
2109
|
+
|
|
2110
|
+
// The reset takes back the user's own commit of round one: a move back adds no commit, so every file
|
|
2111
|
+
// of both rounds is back as before with no commit of the user's since, and the whole work comes.
|
|
2112
|
+
it('D2: committed, the next round applied, then a hard reset to before both: the whole work', async () => {
|
|
2113
|
+
const host = createTestHost();
|
|
2114
|
+
const work = await applied(host);
|
|
2115
|
+
const before = work.g(['rev-parse', 'HEAD']).trim();
|
|
2116
|
+
commit(work, ['a.txt', 'feature.ts']);
|
|
2117
|
+
work.round(b);
|
|
2118
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2119
|
+
work.g(['reset', '--quiet', '--hard', before]);
|
|
2120
|
+
const third = work.round(c);
|
|
2121
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 4 });
|
|
2122
|
+
expect(holdsAll(work, third, ['a.txt', 'b.txt', 'c.txt', 'feature.ts'])).toBe(true);
|
|
2123
|
+
expect(closed(work)).toBe(false);
|
|
2124
|
+
});
|
|
2125
|
+
|
|
2126
|
+
// A revert is a new commit of the user's: the files it put back are the user's change, not a throw-away.
|
|
2127
|
+
it('R: committed, then the commit reverted: an edit, only what is new', async () => {
|
|
2128
|
+
const host = createTestHost();
|
|
2129
|
+
const work = await applied(host);
|
|
2130
|
+
commit(work, ['a.txt', 'feature.ts']);
|
|
2131
|
+
// `git revert`, done by hand beside the user's other uncommitted work: a new commit that puts both back.
|
|
2132
|
+
work.putBack(work.start, ['a.txt', 'feature.ts']);
|
|
2133
|
+
work.g(['add', '--all', '--', 'a.txt', 'feature.ts']);
|
|
2134
|
+
work.g(['commit', '--quiet', '-m', 'Revert round one', '--', 'a.txt', 'feature.ts']);
|
|
2135
|
+
const second = work.round(b);
|
|
2136
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2137
|
+
expect(holdsAll(work, second, ['b.txt'])).toBe(true);
|
|
2138
|
+
expect(holdsAll(work, work.start, ['a.txt', 'feature.ts'])).toBe(true);
|
|
2139
|
+
expect(closed(work)).toBe(false);
|
|
2140
|
+
});
|
|
2141
|
+
|
|
2142
|
+
it('A1: committed, then a file of it deleted and another moved: an edit, and a round that touches them does not fit', async () => {
|
|
2143
|
+
const host = createTestHost();
|
|
2144
|
+
const work = await applied(host);
|
|
2145
|
+
commit(work, ['a.txt', 'feature.ts']);
|
|
2146
|
+
fs.rmSync(path.join(work.repo, 'feature.ts'));
|
|
2147
|
+
fs.mkdirSync(path.join(work.repo, 'lib'));
|
|
2148
|
+
work.g(['mv', 'a.txt', 'lib/a.txt']);
|
|
2149
|
+
work.round(c);
|
|
2150
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2151
|
+
expect(fs.existsSync(path.join(work.repo, 'feature.ts'))).toBe(false);
|
|
2152
|
+
expect(closed(work)).toBe(false);
|
|
2153
|
+
work.round({ 'feature.ts': 'export const x = 2;\n' });
|
|
2154
|
+
const failure = await tryApply(host, work.repo);
|
|
2155
|
+
expect(failure).toMatchObject({ code: 'changes_do_not_apply', details: { exitCode: 1 } });
|
|
2156
|
+
expect(failure.message).toMatch(/^The work of the space does not fit your project any more/);
|
|
2157
|
+
expect(closed(work)).toBe(true);
|
|
2158
|
+
});
|
|
2159
|
+
|
|
2160
|
+
it('A2: committed, then a file of it moved in another commit, and a round that touches it: does not fit', async () => {
|
|
2161
|
+
const host = createTestHost();
|
|
2162
|
+
const work = await applied(host);
|
|
2163
|
+
commit(work, ['a.txt', 'feature.ts']);
|
|
2164
|
+
fs.mkdirSync(path.join(work.repo, 'lib'));
|
|
2165
|
+
work.g(['mv', 'a.txt', 'lib/a.txt']);
|
|
2166
|
+
work.g(['commit', '--quiet', '-m', 'moved', '--', 'a.txt', 'lib/a.txt']);
|
|
2167
|
+
work.round({ 'a.txt': numbered(10, { 5: 'line 5 from round one', 9: 'line 9 from round two' }) });
|
|
2168
|
+
const failure = await tryApply(host, work.repo);
|
|
2169
|
+
expect(failure).toMatchObject({ code: 'changes_do_not_apply', details: { exitCode: 1 } });
|
|
2170
|
+
expect(failure.message).toMatch(/^The work of the space does not fit your project any more/);
|
|
2171
|
+
});
|
|
2172
|
+
|
|
2173
|
+
it('A3: committed, the next round applied, then a file of the first round deleted: not the last apply, not seen', async () => {
|
|
2174
|
+
const host = createTestHost();
|
|
2175
|
+
const work = await applied(host);
|
|
2176
|
+
commit(work, ['a.txt', 'feature.ts']);
|
|
2177
|
+
work.round(b);
|
|
2178
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2179
|
+
fs.rmSync(path.join(work.repo, 'feature.ts'));
|
|
2180
|
+
work.round(c);
|
|
2181
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2182
|
+
expect(fs.existsSync(path.join(work.repo, 'feature.ts'))).toBe(false);
|
|
2183
|
+
expect(closed(work)).toBe(false);
|
|
2184
|
+
});
|
|
2185
|
+
});
|
|
2186
|
+
|
|
2187
|
+
// The verifier's A4: the refs of a space are shared by linked worktrees, HEAD is not. An apply from
|
|
2188
|
+
// a second worktree after one in the first gave round two alone. Its working tree holds nothing of
|
|
2189
|
+
// round one, so round one is thrown away there and the whole work comes; back in the first worktree,
|
|
2190
|
+
// where round one is and round two is not, the part thrown away is refused.
|
|
2191
|
+
it('gives the whole work in a second linked worktree, and refuses the part in the first', async () => {
|
|
2192
|
+
const host = createTestHost();
|
|
2193
|
+
const work = await roundsResult(host);
|
|
2194
|
+
work.round({ 'one.txt': numbered(10, { 3: 'line 3 from round one' }) });
|
|
2195
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2196
|
+
const other = path.join(host.root, `linked-${crypto.randomBytes(4).toString('hex')}`);
|
|
2197
|
+
work.g(['worktree', 'add', '--quiet', '-b', 'feature', other, 'HEAD']);
|
|
2198
|
+
host.sh(other, ['commit', '--quiet', '--allow-empty', '-m', 'work on feature in the other worktree']);
|
|
2199
|
+
const second = work.round({ 'two.txt': numbered(10, { 5: 'line 5 from round two' }) });
|
|
2200
|
+
expect(await apply(host, other)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
2201
|
+
for (const name of ['one.txt', 'two.txt']) expect(fs.readFileSync(path.join(other, name), 'utf8')).toBe(work.g(['show', `${second}:${name}`]));
|
|
2202
|
+
work.round({ 'three.txt': numbered(10, { 1: 'line 1 from round three' }) });
|
|
2203
|
+
await expect(apply(host, work.repo)).rejects.toMatchObject({
|
|
2204
|
+
code: 'changes_do_not_apply', details: { thrownAway: { count: 1, paths: ['two.txt'] }, stillThere: { count: 1, paths: ['one.txt'] } },
|
|
2205
|
+
});
|
|
2206
|
+
});
|
|
2207
|
+
|
|
2208
|
+
// The verifier's G1: the host died after its `git apply`, the user committed that work, and a call
|
|
2209
|
+
// sorted it out. The record keeps the HEAD of the intent, so the commit counts as the user's.
|
|
2210
|
+
it('keeps the HEAD of the intent when it records an apply the host did not live to record', async () => {
|
|
2211
|
+
const host = createTestHost();
|
|
2212
|
+
const { repo, g, round } = await roundsResult(host);
|
|
2213
|
+
round({ 'one.txt': numbered(10, { 3: 'line 3 from round one' }), 'added.txt': 'added\n' });
|
|
2214
|
+
await expect(apply(host, repo, dying(host, { afterApply: true }))).rejects.toThrow();
|
|
2215
|
+
g(['add', 'one.txt', 'added.txt']);
|
|
2216
|
+
g(['commit', '--quiet', '-m', 'kept', '--', 'one.txt', 'added.txt']);
|
|
2217
|
+
expect(await apply(host, repo)).toEqual({ status: 'nothing_to_apply' });
|
|
2218
|
+
fs.rmSync(path.join(repo, 'added.txt'));
|
|
2219
|
+
round({ 'three.txt': numbered(10, { 1: 'line 1 from round two' }) });
|
|
2220
|
+
expect(await describeState(host, repo)).toMatchObject({ newPaths: 1 });
|
|
2221
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2222
|
+
});
|
|
2223
|
+
|
|
2224
|
+
// The verifier's G3: two applies, so the last one did not start from the start, then an attempt that
|
|
2225
|
+
// wrote nothing, and then the user threw the second apply away.
|
|
2226
|
+
it('takes an attempt that wrote nothing for one never started after the second of two applies was thrown away', async () => {
|
|
2227
|
+
const host = createTestHost();
|
|
2228
|
+
const { repo, g, round } = await roundsResult(host);
|
|
2229
|
+
const first = round({ 'one.txt': numbered(10, { 3: 'round one' }) });
|
|
2230
|
+
await apply(host, repo);
|
|
2231
|
+
round({ 'one.txt': numbered(10, { 3: 'round one', 6: 'round two' }) });
|
|
2232
|
+
await apply(host, repo);
|
|
2233
|
+
const third = round({ 'one.txt': numbered(10, { 3: 'round one', 6: 'round two', 9: 'round three' }) });
|
|
2234
|
+
await expect(apply(host, repo, dying(host, { afterApply: false }))).rejects.toThrow();
|
|
2235
|
+
fs.writeFileSync(path.join(repo, 'one.txt'), g(['show', `${first}:one.txt`]));
|
|
2236
|
+
expect(await describeState(host, repo)).toMatchObject({ interruptedApply: true, newPaths: 1, changesRoute: 'open' });
|
|
2237
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2238
|
+
expect(fs.readFileSync(path.join(repo, 'one.txt'), 'utf8')).toBe(g(['show', `${third}:one.txt`]));
|
|
2239
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
2240
|
+
});
|
|
2241
|
+
|
|
2242
|
+
// The verifier's G4: an apply that turns x.txt to CRLF, which the attributes undo, beside one that
|
|
2243
|
+
// changes two.txt visibly, both kept: the next round applies.
|
|
2244
|
+
it.skipIf(WIN)('takes an apply of a change the attributes undo beside a visible one for kept', async () => {
|
|
2245
|
+
const host = createTestHost();
|
|
2246
|
+
const { repo, g, round } = await roundsResult(host);
|
|
2247
|
+
round({ '.gitattributes': 'x.txt text eol=lf\n', 'x.txt': 'a\nb\n' });
|
|
2248
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
2249
|
+
const plumbed = (base, entries) => {
|
|
2250
|
+
const kept = g(['ls-tree', base]).split('\n').filter(Boolean).filter((entry) => !Object.keys(entries).some((name) => entry.endsWith(`\t${name}`)));
|
|
2251
|
+
for (const [name, content] of Object.entries(entries)) kept.push(`100644 blob ${g(['hash-object', '-w', '--no-filters', '--stdin'], { input: content }).trim()}\t${name}`);
|
|
2252
|
+
const commit = g(['commit-tree', g(['mktree'], { input: `${kept.join('\n')}\n` }).trim(), '-p', base, '-m', 'plumbed']).trim();
|
|
2253
|
+
g(['update-ref', RESULT, commit]);
|
|
2254
|
+
return commit;
|
|
2255
|
+
};
|
|
2256
|
+
const second = plumbed(g(['rev-parse', RESULT]).trim(), { 'x.txt': 'a\r\nb\r\n', 'two.txt': numbered(10, { 5: 'round two' }) });
|
|
2257
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
2258
|
+
plumbed(second, { 'three.txt': numbered(10, { 1: 'round three' }) });
|
|
2259
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2260
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
2261
|
+
});
|
|
2262
|
+
|
|
2263
|
+
// The verifier's D1: on a closed route nothing is read back, since no apply would choose a base.
|
|
2264
|
+
it('reads nothing back to describe a closed route', async () => {
|
|
2265
|
+
const host = createTestHost();
|
|
2266
|
+
const { repo, round, putBack, start } = await roundsResult(host);
|
|
2267
|
+
round({ 'one.txt': numbered(10, { 3: 'line 3 from round one' }) });
|
|
2268
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied' });
|
|
2269
|
+
fs.writeFileSync(path.join(repo, 'one.txt'), numbered(10, { 3: 'line 3 of the user' }));
|
|
2270
|
+
round({ 'one.txt': numbered(10, { 3: 'line 3 redone' }) });
|
|
2271
|
+
await expect(apply(host, repo)).rejects.toMatchObject({ code: 'changes_do_not_apply' });
|
|
2272
|
+
putBack(start, ['one.txt']);
|
|
2273
|
+
let opened = 0;
|
|
2274
|
+
const counting = host.codeOut(null, { readBackTimeoutMs: 1, files: { ...fs.promises, open: (...args) => { opened += 1; return fs.promises.open(...args); } } });
|
|
2275
|
+
expect(await describeState(host, repo, counting)).toMatchObject({ changesRoute: 'closed', newPaths: 1, newPathsUndecided: false });
|
|
2276
|
+
expect(opened).toBe(0);
|
|
2277
|
+
});
|
|
2278
|
+
|
|
2279
|
+
// A listing longer than the host reads, while reading back, is answered like one that took too long.
|
|
2280
|
+
it('answers a read-back whose listing is longer than the host reads, and closes the route', async () => {
|
|
2281
|
+
const host = createTestHost();
|
|
2282
|
+
const { repo, g, round, putBack, start } = await roundsResult(host);
|
|
2283
|
+
round({ 'one.txt': numbered(10, { 3: 'line 3 from round one' }) });
|
|
2284
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied' });
|
|
2285
|
+
putBack(start, ['one.txt']);
|
|
2286
|
+
round({ 'two.txt': numbered(10, { 5: 'line 5 from round two' }) });
|
|
2287
|
+
const listing = (args) => args.includes('diff-tree') && args.includes('-z') && !args.includes('--name-only');
|
|
2288
|
+
const tooLong = () => Promise.reject(new SpaceError('command_output_too_large', 'git diff-tree printed more than 67108864 bytes and was stopped'));
|
|
2289
|
+
const long = createCodeOut({
|
|
2290
|
+
git: {
|
|
2291
|
+
run: (directory, args, options) => (listing(args) ? tooLong() : host.git.run(directory, args, options)),
|
|
2292
|
+
output: (directory, args, options) => (listing(args) ? tooLong() : host.git.output(directory, args, options)),
|
|
2293
|
+
},
|
|
2294
|
+
place: null,
|
|
2295
|
+
temporaryDirectory: host.root,
|
|
2296
|
+
});
|
|
2297
|
+
expect(await describeState(host, repo, long)).toMatchObject({ newPaths: null, newPathsUndecided: true });
|
|
2298
|
+
await expect(apply(host, repo, long)).rejects.toMatchObject({ code: 'changes_undecided' });
|
|
2299
|
+
expect(g(['for-each-ref', CLOSED])).not.toBe('');
|
|
2300
|
+
});
|
|
2301
|
+
|
|
2302
|
+
// The verifier's D10c: the time for reading back is the call's, shared by every command of it, not
|
|
2303
|
+
// each command's own. Two reads that each fit within it do not fit together.
|
|
2304
|
+
it.skipIf(WIN)('shares the read-back time among every command of the call', { timeout: 60_000 }, async () => {
|
|
2305
|
+
const host = createTestHost();
|
|
2306
|
+
const { repo, g, round } = await roundsResult(host);
|
|
2307
|
+
round({ 'one.txt': numbered(10, { 3: 'round one' }), 'added.txt': 'added\n' });
|
|
2308
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
2309
|
+
round({ 'two.txt': numbered(10, { 3: 'round two' }), 'three.txt': numbered(10, { 3: 'round two' }) });
|
|
2310
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
2311
|
+
// Edited by the user, so each is read back through the clean filter: half a second a file.
|
|
2312
|
+
fs.writeFileSync(path.join(repo, 'added.txt'), 'added\nand edited\n');
|
|
2313
|
+
for (const name of ['two.txt', 'three.txt']) fs.writeFileSync(path.join(repo, name), numbered(10, { 3: 'round two', 9: 'the user' }));
|
|
2314
|
+
round({ 'one.txt': numbered(10, { 3: 'round one', 7: 'round three' }) });
|
|
2315
|
+
g(['config', 'filter.slow.clean', 'sleep 0.5; cat']);
|
|
2316
|
+
fs.writeFileSync(path.join(repo, '.git', 'info', 'attributes'), '*.txt filter=slow\n');
|
|
2317
|
+
// One file for the look at every apply, then two for the last apply: about 1.5 s in all.
|
|
2318
|
+
await expect(apply(host, repo, host.codeOut(null, { readBackTimeoutMs: 1300 }))).rejects.toMatchObject({ code: 'changes_undecided' });
|
|
2319
|
+
});
|
|
2320
|
+
|
|
2321
|
+
// A clean filter that talks on stderr for every file it reads must not make reading back fail.
|
|
2322
|
+
it.skipIf(WIN)('reads back through a filter that prints a lot for every file', { timeout: 60_000 }, async () => {
|
|
2323
|
+
const host = createTestHost();
|
|
2324
|
+
const own = Array.from({ length: 40 }, (_, index) => `f${String(index).padStart(2, '0')}.txt`);
|
|
2325
|
+
const { repo, g, round } = await roundsResult(host, [...own, 'one.txt']);
|
|
2326
|
+
round(Object.fromEntries(own.map((name) => [name, numbered(10, { 3: 'round one' })])));
|
|
2327
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 40 });
|
|
2328
|
+
for (const name of own) fs.writeFileSync(path.join(repo, name), numbered(10, { 3: 'round one', 9: 'the user' }));
|
|
2329
|
+
round({ 'one.txt': numbered(10, { 1: 'round two' }) });
|
|
2330
|
+
// 200 KB on stderr a file, 8 MB in all, past any cap on what the host keeps of it.
|
|
2331
|
+
g(['config', 'filter.chatty.clean', 'yes x | head -c 200000 >&2; cat']);
|
|
2332
|
+
fs.writeFileSync(path.join(repo, '.git', 'info', 'attributes'), '*.txt filter=chatty\n');
|
|
2333
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2334
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
2335
|
+
});
|
|
2336
|
+
|
|
2337
|
+
// A counting `files` sees what is read. The whole of a last apply that is kept, byte for byte, is read
|
|
2338
|
+
// by the host alone: no git runs for it, however slow the user's clean filter is.
|
|
2339
|
+
it.skipIf(WIN)('reads a kept last apply byte for byte, without running the user\'s clean filter', async () => {
|
|
2340
|
+
const host = createTestHost();
|
|
2341
|
+
const own = ['a.txt', 'b.txt', 'c.txt', 'one.txt'];
|
|
2342
|
+
const { repo, g, round } = await roundsResult(host, own);
|
|
2343
|
+
round(Object.fromEntries(own.slice(0, 3).map((name) => [name, numbered(10, { 3: 'round one' })])));
|
|
2344
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 3 });
|
|
2345
|
+
round({ 'one.txt': numbered(10, { 1: 'round two' }) });
|
|
2346
|
+
const markers = path.join(host.root, 'clean-ran');
|
|
2347
|
+
g(['config', 'filter.slow.clean', `echo ran >> '${forConfig(markers)}'; sleep 2; cat`]);
|
|
2348
|
+
fs.writeFileSync(path.join(repo, '.git', 'info', 'attributes'), '*.txt filter=slow\n');
|
|
2349
|
+
const hashed = [];
|
|
2350
|
+
const counting = host.codeOut(null, { readBackTimeoutMs: 1000, files: { ...fs.promises, open: (file, ...rest) => { hashed.push(path.basename(file)); return fs.promises.open(file, ...rest); } } });
|
|
2351
|
+
expect(await describeState(host, repo, counting)).toMatchObject({ newPaths: 1, newPathsUndecided: false });
|
|
2352
|
+
expect(hashed.sort()).toEqual(['a.txt', 'b.txt', 'c.txt']);
|
|
2353
|
+
expect(fs.existsSync(markers)).toBe(false);
|
|
2354
|
+
});
|
|
2355
|
+
|
|
2356
|
+
// The time of the call is checked while one file is read, not only between git commands: a file that
|
|
2357
|
+
// is slow to read, here a disk that gives 64 KiB every 50 ms, cannot hold the call past its time.
|
|
2358
|
+
it('checks the read-back time while it reads a large file', async () => {
|
|
2359
|
+
const host = createTestHost();
|
|
2360
|
+
const { repo, round } = await roundsResult(host);
|
|
2361
|
+
round({ 'one.txt': numbered(10, { 3: 'round one' }), 'large.bin': Buffer.alloc(40 * 64 * 1024, 7) });
|
|
2362
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
2363
|
+
round({ 'two.txt': numbered(10, { 5: 'round two' }) });
|
|
2364
|
+
const slowDisk = {
|
|
2365
|
+
...fs.promises,
|
|
2366
|
+
open: async (...args) => {
|
|
2367
|
+
const handle = await fs.promises.open(...args);
|
|
2368
|
+
const read = handle.read.bind(handle);
|
|
2369
|
+
handle.read = async (...readArgs) => { await new Promise((resolve) => { setTimeout(resolve, 50); }); return read(...readArgs); };
|
|
2370
|
+
return handle;
|
|
2371
|
+
},
|
|
2372
|
+
};
|
|
2373
|
+
const started = Date.now();
|
|
2374
|
+
expect(await describeState(host, repo, host.codeOut(null, { readBackTimeoutMs: 600, files: slowDisk }))).toMatchObject({ newPaths: null, newPathsUndecided: true });
|
|
2375
|
+
expect(Date.now() - started).toBeLessThan(1800);
|
|
2376
|
+
});
|
|
2377
|
+
|
|
2378
|
+
// An LFS-like filter: the user's file is the real content, git keeps a pointer. The agent changed
|
|
2379
|
+
// such a file; applied, it is the agent's bytes, which the filter reads back as another pointer, so
|
|
2380
|
+
// a second round on it cannot fit. That is said as it is, not as "your project changed".
|
|
2381
|
+
it.skipIf(WIN)('says so when a file an LFS-like filter handles cannot be updated a second time', async () => {
|
|
2382
|
+
const host = createTestHost();
|
|
2383
|
+
const store = path.join(host.root, `lfs-store-${crypto.randomBytes(4).toString('hex')}`);
|
|
2384
|
+
fs.mkdirSync(store);
|
|
2385
|
+
const script = (name, body) => {
|
|
2386
|
+
const file = path.join(host.root, `${name}-${crypto.randomBytes(4).toString('hex')}.sh`);
|
|
2387
|
+
fs.writeFileSync(file, `#!/bin/sh\nt=$(mktemp); cat > "$t"\n${body}\nrm -f "$t"\n`, { mode: 0o755 });
|
|
2388
|
+
return forConfig(file);
|
|
2389
|
+
};
|
|
2390
|
+
const clean = script('lfs-clean', `if [ "$(head -c 4 "$t")" = "ptr " ]; then cat "$t"; else s=$(cksum < "$t" | cut -d' ' -f1); cp "$t" '${store}'/"$s"; printf 'ptr %s\\n' "$s"; fi`);
|
|
2391
|
+
const smudge = script('lfs-smudge', `s=$(sed -n 's/^ptr //p' "$t"); if [ "$(head -c 4 "$t")" = "ptr " ] && [ -f '${store}'/"$s" ]; then cat '${store}'/"$s"; else cat "$t"; fi`);
|
|
2392
|
+
const work = await roundsResult(host, ['one.txt', 'two.txt'], {
|
|
2393
|
+
prepare: ({ repo, g }) => {
|
|
2394
|
+
g(['config', 'filter.fakelfs.clean', clean]);
|
|
2395
|
+
g(['config', 'filter.fakelfs.smudge', smudge]);
|
|
2396
|
+
g(['config', 'filter.fakelfs.required', 'true']);
|
|
2397
|
+
fs.writeFileSync(path.join(repo, '.gitattributes'), '*.bin filter=fakelfs\n');
|
|
2398
|
+
fs.writeFileSync(path.join(repo, 'logo.bin'), 'the real logo\n');
|
|
2399
|
+
// A file of the filter whose object is not here: the working file is the pointer itself, which
|
|
2400
|
+
// the filter reads back as the same pointer, so it is not in the way.
|
|
2401
|
+
fs.writeFileSync(path.join(repo, 'other.bin'), 'ptr 999\n');
|
|
2402
|
+
g(['add', '.gitattributes', 'logo.bin', 'other.bin']);
|
|
2403
|
+
g(['commit', '--quiet', '-m', 'a logo in LFS', '--', '.gitattributes', 'logo.bin', 'other.bin']);
|
|
2404
|
+
},
|
|
2405
|
+
});
|
|
2406
|
+
expect(work.g(['show', 'HEAD:logo.bin'])).toMatch(/^ptr /);
|
|
2407
|
+
work.round({ 'logo.bin': 'the logo of the agent\n', 'one.txt': numbered(10, { 3: 'round one' }) });
|
|
2408
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
2409
|
+
expect(fs.readFileSync(path.join(work.repo, 'logo.bin'), 'utf8')).toBe('the logo of the agent\n');
|
|
2410
|
+
// Kept, and a round on another file: applies.
|
|
2411
|
+
work.round({ 'two.txt': numbered(10, { 5: 'round two' }) });
|
|
2412
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2413
|
+
// A round on the logo again cannot fit, and says why, naming only the file the filter keeps apart.
|
|
2414
|
+
const third = work.round({ 'logo.bin': 'the logo of the agent, again\n', 'other.bin': 'the other of the agent\n' });
|
|
2415
|
+
const before = hostState(work.repo);
|
|
2416
|
+
const failure = await tryApply(host, work.repo);
|
|
2417
|
+
expect(failure).toMatchObject({ code: 'changes_do_not_apply', details: { filteredInTheWay: { count: 1, paths: ['logo.bin'] } } });
|
|
2418
|
+
expect(failure.message).toMatch(/^The work of the space changes logo\.bin 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\./);
|
|
2419
|
+
expect(anyChangeSince(before, work.repo)).toEqual(CLOSURE);
|
|
2420
|
+
expect(work.g(['rev-parse', CLOSED]).trim()).toBe(third);
|
|
2421
|
+
});
|
|
2422
|
+
|
|
2423
|
+
// A folder of the last apply that the user replaced with a link to another place holding the same
|
|
2424
|
+
// file: git status reads the path as gone, and nothing is read through the link.
|
|
2425
|
+
it.skipIf(WIN)('reads a path below a link as gone, and reads nothing through the link', async () => {
|
|
2426
|
+
const host = createTestHost();
|
|
2427
|
+
const { repo, round } = await roundsResult(host);
|
|
2428
|
+
fs.mkdirSync(path.join(host.root, 'elsewhere'));
|
|
2429
|
+
round({ 'one.txt': numbered(10, { 3: 'round one' }), 'dir/x.txt': 'x\n' });
|
|
2430
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
2431
|
+
fs.renameSync(path.join(repo, 'dir'), path.join(host.root, 'elsewhere', 'dir'));
|
|
2432
|
+
fs.symlinkSync(path.join(host.root, 'elsewhere', 'dir'), path.join(repo, 'dir'));
|
|
2433
|
+
round({ 'two.txt': numbered(10, { 5: 'round two' }) });
|
|
2434
|
+
await expect(apply(host, repo)).rejects.toMatchObject({ code: 'changes_do_not_apply', details: { thrownAway: { paths: ['dir/x.txt'] }, stillThere: { paths: ['one.txt'] } } });
|
|
2435
|
+
});
|
|
2436
|
+
|
|
2437
|
+
// Where git here does not see the exec bit, a change of it alone cannot be told apart on disk, so it
|
|
2438
|
+
// is no evidence either way: thrown away beside a visible change, the whole work comes again.
|
|
2439
|
+
it.skipIf(WIN)('leaves a change of the exec bit alone out where core.filemode is off', async () => {
|
|
2440
|
+
const host = createTestHost();
|
|
2441
|
+
const { repo, g, start, round, putBack } = await roundsResult(host);
|
|
2442
|
+
g(['config', 'core.filemode', 'false']);
|
|
2443
|
+
const first = round({ 'one.txt': numbered(10, { 3: 'round one' }) });
|
|
2444
|
+
const two = g(['ls-tree', first, '--', 'two.txt']).trim().split(/\s/)[2];
|
|
2445
|
+
g(['update-ref', RESULT, g(['commit-tree', g(['mktree'], { input: g(['ls-tree', first]).replace(`100644 blob ${two}\ttwo.txt`, `100755 blob ${two}\ttwo.txt`) }).trim(), '-p', first, '-m', 'plumbed']).trim()]);
|
|
2446
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
2447
|
+
putBack(start, ['one.txt']);
|
|
2448
|
+
const applied = g(['rev-parse', RESULT]).trim();
|
|
2449
|
+
const three = g(['hash-object', '-w', '--stdin'], { input: numbered(10, { 1: 'round two' }) }).trim();
|
|
2450
|
+
const tree = g(['ls-tree', applied]).split('\n').filter((entry) => entry !== '' && !entry.endsWith('\tthree.txt')).concat(`100644 blob ${three}\tthree.txt`).join('\n');
|
|
2451
|
+
g(['update-ref', RESULT, g(['commit-tree', g(['mktree'], { input: `${tree}\n` }).trim(), '-p', applied, '-m', 'plumbed']).trim()]);
|
|
2452
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 3 });
|
|
2453
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
2454
|
+
});
|
|
2455
|
+
|
|
2456
|
+
// A name the agent made with a line break in it, edited by the user, is read back on its own: the
|
|
2457
|
+
// line-by-line input of `hash-object --stdin-paths` cannot carry it.
|
|
2458
|
+
it.skipIf(WIN)('reads back a file whose name holds a line break', async () => {
|
|
2459
|
+
const host = createTestHost();
|
|
2460
|
+
const { repo, g, round } = await roundsResult(host);
|
|
2461
|
+
round({ 'one.txt': numbered(10, { 3: 'round one' }), 'odd\nname.txt': numbered(10) });
|
|
2462
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
2463
|
+
fs.writeFileSync(path.join(repo, 'odd\nname.txt'), numbered(10, { 9: 'the user' }));
|
|
2464
|
+
fs.writeFileSync(path.join(repo, 'one.txt'), numbered(10, { 3: 'round one', 9: 'the user' }));
|
|
2465
|
+
round({ 'two.txt': numbered(10, { 5: 'round two' }) });
|
|
2466
|
+
expect(await apply(host, repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2467
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
2468
|
+
});
|
|
2469
|
+
|
|
2470
|
+
describe.skipIf(WIN)('the attributes it reads back with', () => {
|
|
2471
|
+
// Each filter program notes where it ran and on what, so a run in a folder of ours would show.
|
|
2472
|
+
const filterHost = () => {
|
|
2473
|
+
const host = createTestHost();
|
|
2474
|
+
const markers = path.join(host.root, 'filter-markers');
|
|
2475
|
+
fs.mkdirSync(markers);
|
|
2476
|
+
const filter = (name, step) => {
|
|
2477
|
+
const program = path.join(host.root, `${name}-${step}.sh`);
|
|
2478
|
+
fs.writeFileSync(program, `#!/bin/sh\necho "$PWD|$1" >> '${markers}/${name}-${step}'\ncat\n`, { mode: 0o755 });
|
|
2479
|
+
return forConfig(program);
|
|
2480
|
+
};
|
|
2481
|
+
host.addConfig([
|
|
2482
|
+
'[filter "mine"]', `\tclean = ${filter('mine', 'clean')} %f`, `\tsmudge = ${filter('mine', 'smudge')} %f`,
|
|
2483
|
+
'[filter "arriving"]', `\tclean = ${filter('arriving', 'clean')} %f`, `\tsmudge = ${filter('arriving', 'smudge')} %f`,
|
|
2484
|
+
].join('\n'));
|
|
2485
|
+
const ran = () => Object.fromEntries(fs.readdirSync(markers).sort().map((marker) => [marker, fs.readFileSync(path.join(markers, marker), 'utf8').split('\n').filter(Boolean)]));
|
|
2486
|
+
const clear = () => { for (const marker of fs.readdirSync(markers)) fs.rmSync(path.join(markers, marker)); };
|
|
2487
|
+
return { host, ran, clear };
|
|
2488
|
+
};
|
|
2489
|
+
const own = (repo, g, files) => {
|
|
2490
|
+
for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(repo, name), content);
|
|
2491
|
+
g(['add', ...Object.keys(files)]);
|
|
2492
|
+
g(['commit', '--quiet', '-m', 'files of the user', '--', ...Object.keys(files)]);
|
|
2493
|
+
};
|
|
2494
|
+
|
|
2495
|
+
// The verifier's A1: the space changes the user's .gitattributes to name the arriving filter for
|
|
2496
|
+
// every file, and changes data.bin and x.mine. The user throws the apply away but for an edit of
|
|
2497
|
+
// x.mine, so the working tree holds only the user's own attributes again: x.mine is read back
|
|
2498
|
+
// through the user's filter, in the user's folder, and the arriving filter and no smudge run.
|
|
2499
|
+
it('reads back with the attributes of the working tree now, never with a thrown-away .gitattributes', async () => {
|
|
2500
|
+
const { host, ran, clear } = filterHost();
|
|
2501
|
+
const work = await roundsResult(host, ['one.txt'], { prepare: ({ repo, g }) => own(repo, g, { '.gitattributes': '*.mine filter=mine\n', 'data.bin': numbered(10), 'x.mine': numbered(10) }) });
|
|
2502
|
+
work.round({
|
|
2503
|
+
'.gitattributes': '*.mine filter=mine\n* filter=arriving\n', 'data.bin': numbered(10, { 3: 'from the space' }), 'x.mine': numbered(10, { 3: 'from the space' }),
|
|
2504
|
+
'.lfsconfig': '[lfs]\n\turl = https://lfs.invalid\n', '.gitmodules': '[submodule "x"]\n\tpath = x\n\turl = https://invalid\n',
|
|
2505
|
+
});
|
|
2506
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 5 });
|
|
2507
|
+
work.putBack(work.start, ['.gitattributes', 'data.bin', '.lfsconfig', '.gitmodules']);
|
|
2508
|
+
clear();
|
|
2509
|
+
// x.mine is kept, the rest thrown away: a part, which the apply refuses, so it counts from the last apply.
|
|
2510
|
+
expect(await describeState(host, work.repo)).toMatchObject({ newPaths: 0, newPathsUndecided: false });
|
|
2511
|
+
expect(ran()).toEqual({});
|
|
2512
|
+
fs.writeFileSync(path.join(work.repo, 'x.mine'), numbered(10, { 3: 'from the space', 9: 'line 9 of the user' }));
|
|
2513
|
+
clear();
|
|
2514
|
+
expect(await describeState(host, work.repo)).toMatchObject({ newPaths: 0, newPathsUndecided: false });
|
|
2515
|
+
expect(ran()).toEqual({ 'mine-clean': [`${fs.realpathSync(work.repo)}|x.mine`] });
|
|
2516
|
+
await expect(apply(host, work.repo)).rejects.toMatchObject({ code: 'changes_do_not_apply', details: { stillThere: { paths: ['x.mine'] } } });
|
|
2517
|
+
expect(Object.keys(ran()).filter((marker) => marker.startsWith('arriving') || marker.endsWith('-smudge'))).toEqual([]);
|
|
2518
|
+
});
|
|
2519
|
+
|
|
2520
|
+
// The verifier's A3: the host died before its `git apply` of a patch that adds a .gitattributes
|
|
2521
|
+
// naming the arriving filter. It was never written, so reading the working tree back, at describe
|
|
2522
|
+
// and at the next apply, must not run it.
|
|
2523
|
+
it('runs no filter a .gitattributes of an interrupted apply named, which never reached the working tree', async () => {
|
|
2524
|
+
const { host, ran, clear } = filterHost();
|
|
2525
|
+
const work = await roundsResult(host, ['one.txt'], { prepare: ({ repo, g }) => own(repo, g, { 'data.bin': numbered(10) }) });
|
|
2526
|
+
work.round({ '.gitattributes': '* filter=arriving\n', 'data.bin': numbered(10, { 3: 'from the space' }) });
|
|
2527
|
+
await expect(apply(host, work.repo, dying(host, { afterApply: false }))).rejects.toThrow();
|
|
2528
|
+
expect(fs.existsSync(path.join(work.repo, '.gitattributes'))).toBe(false);
|
|
2529
|
+
// Edited by the user, so data.bin is read back through whatever filter its attributes name.
|
|
2530
|
+
fs.writeFileSync(path.join(work.repo, 'data.bin'), numbered(10, { 9: 'line 9 of the user' }));
|
|
2531
|
+
clear();
|
|
2532
|
+
expect(await describeState(host, work.repo)).toMatchObject({ interruptedApply: true });
|
|
2533
|
+
expect(Object.keys(ran()).filter((marker) => marker.startsWith('arriving'))).toEqual([]);
|
|
2534
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
2535
|
+
expect(Object.keys(ran()).filter((marker) => marker.startsWith('arriving'))).toEqual([]);
|
|
2536
|
+
});
|
|
2537
|
+
|
|
2538
|
+
// A .gitattributes the user kept from an apply is the user's now: the apply itself honours it, and
|
|
2539
|
+
// so does reading back.
|
|
2540
|
+
it('reads back with a .gitattributes the user kept from an apply, as the apply itself does', async () => {
|
|
2541
|
+
const { host, ran, clear } = filterHost();
|
|
2542
|
+
const work = await roundsResult(host, ['one.txt'], { prepare: ({ repo, g }) => own(repo, g, { '.gitattributes': '*.mine filter=mine\n', 'data.bin': numbered(10) }) });
|
|
2543
|
+
work.round({ '.gitattributes': '*.mine filter=mine\n*.bin filter=arriving\n', 'data.bin': numbered(10, { 3: 'from the space' }) });
|
|
2544
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
2545
|
+
fs.writeFileSync(path.join(work.repo, 'data.bin'), numbered(10, { 3: 'from the space', 9: 'line 9 of the user' }));
|
|
2546
|
+
work.round({ '.gitattributes': '*.mine filter=mine\n*.bin filter=arriving\n', 'data.bin': numbered(10, { 3: 'from the space', 5: 'from round two' }) });
|
|
2547
|
+
clear();
|
|
2548
|
+
expect(await describeState(host, work.repo)).toMatchObject({ newPaths: 1, newPathsUndecided: false });
|
|
2549
|
+
expect(ran()['arriving-clean']).toEqual([`${fs.realpathSync(work.repo)}|data.bin`]);
|
|
2550
|
+
clear();
|
|
2551
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2552
|
+
expect(ran()['arriving-clean']?.length).toBeGreaterThan(0);
|
|
2553
|
+
expect(fs.readFileSync(path.join(work.repo, 'data.bin'), 'utf8')).toBe(numbered(10, { 3: 'from the space', 5: 'from round two', 9: 'line 9 of the user' }));
|
|
2554
|
+
});
|
|
2555
|
+
});
|
|
2556
|
+
});
|
|
2557
|
+
|
|
2558
|
+
describe('applyAsChanges after round eight', () => {
|
|
2559
|
+
const apply = (host, repo, codeOut = host.codeOut()) => codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID });
|
|
2560
|
+
const tryApply = (host, repo, codeOut) => apply(host, repo, codeOut).catch((error) => error);
|
|
2561
|
+
const describeState = (host, repo, codeOut = host.codeOut()) => codeOut.describeApplyState({ repository: repo, spaceId: SPACE_ID });
|
|
2562
|
+
const closed = ({ g }) => g(['for-each-ref', CLOSED]) !== '';
|
|
2563
|
+
const commitAll = ({ g }, message) => {
|
|
2564
|
+
g(['add', '--all']);
|
|
2565
|
+
g(['commit', '--quiet', '-m', message]);
|
|
2566
|
+
};
|
|
2567
|
+
/** Whether the process `pid` still runs, asked with signal 0, which signals nothing. */
|
|
2568
|
+
const alive = (pid) => {
|
|
2569
|
+
if (!Number.isInteger(pid) || pid <= 1) throw new Error(`not a pid to ask about: ${pid}`);
|
|
2570
|
+
try {
|
|
2571
|
+
process.kill(pid, 0);
|
|
2572
|
+
return true;
|
|
2573
|
+
} catch {
|
|
2574
|
+
return false;
|
|
2575
|
+
}
|
|
2576
|
+
};
|
|
2577
|
+
/** Waits up to `ms` for the pids written into `file` to be gone, and resolves those still alive. */
|
|
2578
|
+
const leftAfter = async (file, ms = 3000) => {
|
|
2579
|
+
const pids = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(Number);
|
|
2580
|
+
expect(pids.length).toBeGreaterThan(0);
|
|
2581
|
+
const until = Date.now() + ms;
|
|
2582
|
+
while (pids.some(alive) && Date.now() < until) await new Promise((resolve) => { setTimeout(resolve, 100); });
|
|
2583
|
+
return pids.filter(alive);
|
|
2584
|
+
};
|
|
2585
|
+
|
|
2586
|
+
// The verifier's P-magic2. The user undid round one by commits, deleting the space's file named like
|
|
2587
|
+
// pathspec magic: both paths are the user's committed edit, so only round two comes. Read as magic,
|
|
2588
|
+
// `:(exclude)a.txt` would have hidden the commit of a.txt, and round one would have come back over it.
|
|
2589
|
+
// Not on Windows: pathspec magic starts with ':' and its globs use '*', and neither can be in a name
|
|
2590
|
+
// there, so such a file cannot be in a Windows work tree and its magic cannot reach the log.
|
|
2591
|
+
it.skipIf(WIN)('takes a name of the space that reads like pathspec magic as a name when it looks for the user\'s commits', async () => {
|
|
2592
|
+
const host = createTestHost();
|
|
2593
|
+
const work = await roundsResult(host, ['a.txt', 'b.txt']);
|
|
2594
|
+
work.round({ 'a.txt': numbered(10, { 5: 'round one' }), ':(exclude)a.txt': 'magic\n' });
|
|
2595
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
2596
|
+
commitAll(work, 'kept');
|
|
2597
|
+
fs.writeFileSync(path.join(work.repo, 'a.txt'), work.g(['show', `${work.start}:a.txt`]));
|
|
2598
|
+
fs.rmSync(path.join(work.repo, ':(exclude)a.txt'));
|
|
2599
|
+
commitAll(work, 'round one undone by a commit');
|
|
2600
|
+
work.round({ 'b.txt': numbered(10, { 5: 'round two' }) });
|
|
2601
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2602
|
+
expect(fs.readFileSync(path.join(work.repo, 'a.txt'), 'utf8')).toBe(numbered(10));
|
|
2603
|
+
expect(closed(work)).toBe(false);
|
|
2604
|
+
});
|
|
2605
|
+
|
|
2606
|
+
// The verifier's O-odd: a name with a line break, which the user deleted by a commit, is the user's
|
|
2607
|
+
// edit, found in the NUL-separated list of the log.
|
|
2608
|
+
it.skipIf(WIN)('takes a name with a line break that the user deleted by a commit for the user\'s edit', async () => {
|
|
2609
|
+
const host = createTestHost();
|
|
2610
|
+
const work = await roundsResult(host, ['a.txt', 'b.txt']);
|
|
2611
|
+
work.round({ 'odd\nname.txt': 'odd\n' });
|
|
2612
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2613
|
+
commitAll(work, 'kept');
|
|
2614
|
+
fs.rmSync(path.join(work.repo, 'odd\nname.txt'));
|
|
2615
|
+
commitAll(work, 'deleted by the user');
|
|
2616
|
+
work.round({ 'b.txt': numbered(10, { 5: 'round two' }) });
|
|
2617
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2618
|
+
expect(fs.existsSync(path.join(work.repo, 'odd\nname.txt'))).toBe(false);
|
|
2619
|
+
});
|
|
2620
|
+
|
|
2621
|
+
// The verifier's M-mixed: the answer is per path. a.txt went back by a commit, the user's edit;
|
|
2622
|
+
// b.txt went back without one, thrown away: a part. The file still there is committed as it is, so
|
|
2623
|
+
// the refusal gives no advice about clearing the way for the branch, which git would not refuse.
|
|
2624
|
+
it('answers per path, a committed undo beside one without a commit being a part, and advises only where git would refuse', async () => {
|
|
2625
|
+
const host = createTestHost();
|
|
2626
|
+
const work = await roundsResult(host, ['a.txt', 'b.txt', 'c.txt']);
|
|
2627
|
+
work.round({ 'a.txt': numbered(10, { 5: 'round one' }), 'b.txt': numbered(10, { 5: 'round one' }) });
|
|
2628
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
2629
|
+
work.g(['commit', '--quiet', '-m', 'kept a only', '--', 'a.txt']);
|
|
2630
|
+
work.putBack(work.start, ['a.txt']);
|
|
2631
|
+
work.g(['commit', '--quiet', '-m', 'undo a', '--', 'a.txt']);
|
|
2632
|
+
work.putBack(work.start, ['b.txt']);
|
|
2633
|
+
work.round({ 'c.txt': numbered(10, { 5: 'round two' }) });
|
|
2634
|
+
const failure = await tryApply(host, work.repo);
|
|
2635
|
+
expect(failure).toMatchObject({ code: 'changes_do_not_apply', details: { thrownAway: { count: 1, paths: ['b.txt'] }, stillThere: { count: 1, paths: ['a.txt'] } } });
|
|
2636
|
+
expect(failure.message).toBe('You threw away part of what was last applied from this space: b.txt is back as before, while a.txt is still there or changed since. An apply now would leave out what you threw away without a word, so nothing was changed. From now on this space is applied as a branch, which holds its whole work, the rounds you already applied included.');
|
|
2637
|
+
expect(closed(work)).toBe(true);
|
|
2638
|
+
});
|
|
2639
|
+
|
|
2640
|
+
// The verifier's RB-fail: a clean filter of the user's that fails while reading back is not taken for
|
|
2641
|
+
// any answer: nothing is known, the route closes, and the cause says so.
|
|
2642
|
+
it.skipIf(WIN)('answers a clean filter that fails while reading back with changes_undecided, read_back_failed', async () => {
|
|
2643
|
+
const host = createTestHost();
|
|
2644
|
+
const failing = path.join(host.root, 'failing-clean.sh');
|
|
2645
|
+
fs.writeFileSync(failing, '#!/bin/sh\ncat >/dev/null\necho broken >&2\nexit 3\n', { mode: 0o755 });
|
|
2646
|
+
const work = await roundsResult(host, ['a.txt', 'b.txt']);
|
|
2647
|
+
work.round({ 'a.txt': numbered(10, { 5: 'round one' }) });
|
|
2648
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied' });
|
|
2649
|
+
host.addConfig(`[filter "failing"]\n\tclean = ${forConfig(failing)}\n\trequired = true`);
|
|
2650
|
+
fs.writeFileSync(path.join(work.repo, '.git', 'info', 'attributes'), 'a.txt filter=failing\n');
|
|
2651
|
+
fs.writeFileSync(path.join(work.repo, 'a.txt'), numbered(10, { 5: 'round one', 9: 'the user' }));
|
|
2652
|
+
work.round({ 'b.txt': numbered(10, { 5: 'round two' }) });
|
|
2653
|
+
expect(await describeState(host, work.repo)).toMatchObject({ newPaths: null, newPathsUndecided: true });
|
|
2654
|
+
const failure = await tryApply(host, work.repo);
|
|
2655
|
+
expect(failure).toMatchObject({ code: 'changes_undecided', details: { cause: 'read_back_failed', recorded: true } });
|
|
2656
|
+
expect(failure.message).toMatch(/Git said: .*broken/);
|
|
2657
|
+
expect(closed(work)).toBe(true);
|
|
2658
|
+
});
|
|
2659
|
+
|
|
2660
|
+
// The list of what the user committed since the last apply is read whole, up to 64 MiB. Here it is
|
|
2661
|
+
// about 5 MB, past the 4 MiB a host git call keeps by default, and the answer is still exact.
|
|
2662
|
+
it('reads a list of the user\'s commits longer than four megabytes, and answers from it', async () => {
|
|
2663
|
+
const host = createTestHost();
|
|
2664
|
+
const work = await roundsResult(host);
|
|
2665
|
+
work.round({ 'one.txt': numbered(10, { 3: 'round one' }) });
|
|
2666
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2667
|
+
work.putBack(work.start, ['one.txt']);
|
|
2668
|
+
const branch = work.g(['symbolic-ref', 'HEAD']).trim();
|
|
2669
|
+
const lines = ['blob', 'mark :1', 'data 5', 'bulk', `commit ${branch}`, 'committer u <u@example.invalid> 0 +0000', 'data 4', 'bulk', `from ${work.g(['rev-parse', 'HEAD']).trim()}`];
|
|
2670
|
+
for (let index = 0; index < 60_000; index += 1) lines.push(`M 100644 :1 bulk/${index % 300}/${'x'.repeat(66)}-${index}.txt`);
|
|
2671
|
+
work.g(['fast-import', '--quiet', '--force'], { input: `${lines.join('\n')}\n\n` });
|
|
2672
|
+
const listed = spawnSync('git', ['-C', work.repo, 'log', '--format=', '--name-only', '-z', '-1'], { env: host.environment, maxBuffer: 64 * 1024 * 1024 });
|
|
2673
|
+
expect(listed.stdout.length).toBeGreaterThan(4 * 1024 * 1024);
|
|
2674
|
+
work.round({ 'two.txt': numbered(10, { 5: 'round two' }) });
|
|
2675
|
+
// one.txt is back and no commit touched it: thrown away, so the whole work comes.
|
|
2676
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
2677
|
+
expect(closed(work)).toBe(false);
|
|
2678
|
+
});
|
|
2679
|
+
|
|
2680
|
+
// One list of the user's commits per call, over every path, whatever the call asks of it: here the
|
|
2681
|
+
// look at every apply asks about a.txt and the look at the last apply about c.txt. Asked path by
|
|
2682
|
+
// path, a reviewer measured 112 s for a hundred thousand paths over ten thousand commits.
|
|
2683
|
+
it('asks git for the user\'s commits once per call, over every path', async () => {
|
|
2684
|
+
const host = createTestHost();
|
|
2685
|
+
const work = await roundsResult(host, ['a.txt', 'b.txt', 'c.txt']);
|
|
2686
|
+
work.round({ 'a.txt': numbered(10, { 5: 'round one' }) });
|
|
2687
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2688
|
+
work.round({ 'b.txt': numbered(10, { 5: 'round two' }), 'c.txt': numbered(10, { 5: 'round two' }) });
|
|
2689
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
2690
|
+
fs.writeFileSync(path.join(work.repo, 'CHANGES.md'), 'from a colleague\n');
|
|
2691
|
+
work.g(['add', 'CHANGES.md']);
|
|
2692
|
+
work.g(['commit', '--quiet', '-m', 'a colleague', '--', 'CHANGES.md']);
|
|
2693
|
+
const logs = [];
|
|
2694
|
+
const counting = createCodeOut({
|
|
2695
|
+
git: {
|
|
2696
|
+
run: (directory, args, options) => host.git.run(directory, args, options),
|
|
2697
|
+
output: (directory, args, options) => {
|
|
2698
|
+
if (args.includes('log')) logs.push(args.slice(args.indexOf('--') + 1));
|
|
2699
|
+
return host.git.output(directory, args, options);
|
|
2700
|
+
},
|
|
2701
|
+
},
|
|
2702
|
+
place: null,
|
|
2703
|
+
temporaryDirectory: host.root,
|
|
2704
|
+
});
|
|
2705
|
+
// Everything kept: no path is back as before, so no commit is asked about.
|
|
2706
|
+
expect(await describeState(host, work.repo, counting)).toMatchObject({ newPaths: 0, newPathsUndecided: false });
|
|
2707
|
+
expect(logs).toEqual([]);
|
|
2708
|
+
work.putBack(work.start, ['a.txt', 'c.txt']);
|
|
2709
|
+
// b.txt is kept, so the whole work does not come; c.txt is back as before: a part.
|
|
2710
|
+
expect(await describeState(host, work.repo, counting)).toMatchObject({ newPaths: 0, newPathsUndecided: false });
|
|
2711
|
+
expect(logs).toEqual([[]]);
|
|
2712
|
+
await expect(apply(host, work.repo, counting)).rejects.toMatchObject({ code: 'changes_do_not_apply', details: { thrownAway: { paths: ['c.txt'] }, stillThere: { paths: ['b.txt'] } } });
|
|
2713
|
+
expect(logs).toEqual([[], []]);
|
|
2714
|
+
});
|
|
2715
|
+
|
|
2716
|
+
// Past the listing cap nothing is known: the call is changes_undecided and the route closes.
|
|
2717
|
+
it('answers a list of the user\'s commits longer than the host reads with changes_undecided', async () => {
|
|
2718
|
+
const host = createTestHost();
|
|
2719
|
+
const work = await roundsResult(host);
|
|
2720
|
+
work.round({ 'one.txt': numbered(10, { 3: 'round one' }) });
|
|
2721
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied' });
|
|
2722
|
+
work.putBack(work.start, ['one.txt']);
|
|
2723
|
+
fs.writeFileSync(path.join(work.repo, 'CHANGES.md'), 'from a colleague\n');
|
|
2724
|
+
work.g(['add', 'CHANGES.md']);
|
|
2725
|
+
work.g(['commit', '--quiet', '-m', 'a colleague', '--', 'CHANGES.md']);
|
|
2726
|
+
work.round({ 'two.txt': numbered(10, { 5: 'round two' }) });
|
|
2727
|
+
const isLog = (args) => args.includes('log');
|
|
2728
|
+
const long = createCodeOut({
|
|
2729
|
+
git: {
|
|
2730
|
+
run: (directory, args, options) => host.git.run(directory, args, options),
|
|
2731
|
+
output: (directory, args, options) => (isLog(args)
|
|
2732
|
+
? Promise.reject(new SpaceError('command_output_too_large', 'git log printed more than 67108864 bytes and was stopped'))
|
|
2733
|
+
: host.git.output(directory, args, options)),
|
|
2734
|
+
},
|
|
2735
|
+
place: null,
|
|
2736
|
+
temporaryDirectory: host.root,
|
|
2737
|
+
});
|
|
2738
|
+
expect(await describeState(host, work.repo, long)).toMatchObject({ newPaths: null, newPathsUndecided: true });
|
|
2739
|
+
await expect(apply(host, work.repo, long)).rejects.toMatchObject({ code: 'changes_undecided' });
|
|
2740
|
+
expect(closed(work)).toBe(true);
|
|
2741
|
+
});
|
|
2742
|
+
|
|
2743
|
+
// A HEAD with no commit, after a checkout of an orphan branch, has no commit of the user's since the
|
|
2744
|
+
// apply: what is back as before is thrown away.
|
|
2745
|
+
it('reads a HEAD with no commit as no commit of the user\'s since the apply', async () => {
|
|
2746
|
+
const host = createTestHost();
|
|
2747
|
+
const work = await roundsResult(host);
|
|
2748
|
+
work.round({ 'one.txt': numbered(10, { 3: 'round one' }) });
|
|
2749
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2750
|
+
work.g(['checkout', '--quiet', '--orphan', 'fresh']);
|
|
2751
|
+
work.putBack(work.start, ['one.txt']);
|
|
2752
|
+
const second = work.round({ 'two.txt': numbered(10, { 5: 'round two' }) });
|
|
2753
|
+
expect(await describeState(host, work.repo)).toMatchObject({ newPaths: 2, newPathsUndecided: false });
|
|
2754
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 2 });
|
|
2755
|
+
expect(fs.readFileSync(path.join(work.repo, 'one.txt'), 'utf8')).toBe(work.g(['show', `${second}:one.txt`]));
|
|
2756
|
+
});
|
|
2757
|
+
|
|
2758
|
+
// The verifier's W2: between the host's lstat and its open, a.txt becomes a FIFO. The open does not
|
|
2759
|
+
// wait for a writer, and what it opened is no plain file: nothing is known, and git is not asked
|
|
2760
|
+
// instead. Before, the open waited for ever and no deadline was checked.
|
|
2761
|
+
it.skipIf(WIN)('answers a FIFO swapped in after the look at a file with changes_undecided, at once', async () => {
|
|
2762
|
+
const host = createTestHost();
|
|
2763
|
+
const work = await roundsResult(host, ['a.txt', 'b.txt']);
|
|
2764
|
+
work.round({ 'a.txt': numbered(10, { 5: 'round one' }) });
|
|
2765
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied' });
|
|
2766
|
+
work.round({ 'b.txt': numbered(10, { 5: 'round two' }) });
|
|
2767
|
+
const target = path.join(fs.realpathSync(work.repo), 'a.txt');
|
|
2768
|
+
let swapped = 0;
|
|
2769
|
+
const swapping = {
|
|
2770
|
+
...fs.promises,
|
|
2771
|
+
lstat: async (file, ...rest) => {
|
|
2772
|
+
const stats = await fs.promises.lstat(file, ...rest);
|
|
2773
|
+
if (file === target && swapped === 0) {
|
|
2774
|
+
swapped += 1;
|
|
2775
|
+
fs.rmSync(file);
|
|
2776
|
+
expect(spawnSync('mkfifo', [file]).status).toBe(0);
|
|
2777
|
+
}
|
|
2778
|
+
return stats;
|
|
2779
|
+
},
|
|
2780
|
+
};
|
|
2781
|
+
const started = Date.now();
|
|
2782
|
+
try {
|
|
2783
|
+
const outcome = await Promise.race([
|
|
2784
|
+
describeState(host, work.repo, host.codeOut(null, { files: swapping })),
|
|
2785
|
+
new Promise((resolve) => { setTimeout(() => resolve('still waiting'), 5000); }),
|
|
2786
|
+
]);
|
|
2787
|
+
expect(swapped).toBe(1);
|
|
2788
|
+
expect(outcome).toMatchObject({ newPaths: null, newPathsUndecided: true });
|
|
2789
|
+
expect(Date.now() - started).toBeLessThan(5000);
|
|
2790
|
+
} finally {
|
|
2791
|
+
// An open that waits for a writer, as before, is let go by one.
|
|
2792
|
+
try {
|
|
2793
|
+
fs.closeSync(fs.openSync(target, fs.constants.O_WRONLY | fs.constants.O_NONBLOCK));
|
|
2794
|
+
} catch {
|
|
2795
|
+
// No reader waiting.
|
|
2796
|
+
}
|
|
2797
|
+
fs.rmSync(target, { force: true });
|
|
2798
|
+
}
|
|
2799
|
+
});
|
|
2800
|
+
|
|
2801
|
+
// A link to a file outside the project, swapped in after the host's lstat, holding exactly what the
|
|
2802
|
+
// apply wrote: it is not followed, and no git is asked to read it instead. Read through, the user's
|
|
2803
|
+
// edit would have been taken for the kept apply.
|
|
2804
|
+
it.skipIf(WIN)('follows no link swapped in after the look at a file, and hands it to no git', async () => {
|
|
2805
|
+
const host = createTestHost();
|
|
2806
|
+
const work = await roundsResult(host, ['a.txt', 'b.txt']);
|
|
2807
|
+
const first = work.round({ 'a.txt': numbered(10, { 5: 'round one' }) });
|
|
2808
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied' });
|
|
2809
|
+
work.round({ 'b.txt': numbered(10, { 5: 'round two' }) });
|
|
2810
|
+
const outside = path.join(host.root, `outside-${crypto.randomBytes(4).toString('hex')}.txt`);
|
|
2811
|
+
fs.writeFileSync(outside, work.g(['show', `${first}:a.txt`]));
|
|
2812
|
+
fs.writeFileSync(path.join(work.repo, 'a.txt'), 'the user\'s edit\n');
|
|
2813
|
+
const target = path.join(fs.realpathSync(work.repo), 'a.txt');
|
|
2814
|
+
let swapped = 0;
|
|
2815
|
+
const swapping = {
|
|
2816
|
+
...fs.promises,
|
|
2817
|
+
lstat: async (file, ...rest) => {
|
|
2818
|
+
const stats = await fs.promises.lstat(file, ...rest);
|
|
2819
|
+
if (file === target && swapped === 0) {
|
|
2820
|
+
swapped += 1;
|
|
2821
|
+
fs.rmSync(file);
|
|
2822
|
+
fs.symlinkSync(outside, file);
|
|
2823
|
+
}
|
|
2824
|
+
return stats;
|
|
2825
|
+
},
|
|
2826
|
+
};
|
|
2827
|
+
const failure = await tryApply(host, work.repo, host.codeOut(null, { files: swapping }));
|
|
2828
|
+
expect(swapped).toBe(1);
|
|
2829
|
+
expect(failure).toMatchObject({ code: 'changes_undecided', details: { cause: 'read_back_failed' } });
|
|
2830
|
+
expect(failure.message).toMatch(/a\.txt could not be read as a plain file\./);
|
|
2831
|
+
});
|
|
2832
|
+
|
|
2833
|
+
// The real apply is killed with its tree on a timeout, but left to finish when the server leaves: in
|
|
2834
|
+
// the middle it would leave a part written and close the route.
|
|
2835
|
+
it('runs the real apply with its tree killed on a timeout and kept at the server\'s exit', async () => {
|
|
2836
|
+
const host = createTestHost();
|
|
2837
|
+
const work = await roundsResult(host);
|
|
2838
|
+
work.round({ 'one.txt': numbered(10, { 3: 'round one' }) });
|
|
2839
|
+
const seen = [];
|
|
2840
|
+
const recording = createCodeOut({
|
|
2841
|
+
git: {
|
|
2842
|
+
run: (directory, args, options) => {
|
|
2843
|
+
if (args.includes('apply')) seen.push({ check: args.includes('--check'), killTree: options.killTree, keepAtExit: options.keepAtExit ?? false });
|
|
2844
|
+
return host.git.run(directory, args, options);
|
|
2845
|
+
},
|
|
2846
|
+
output: (directory, args, options) => host.git.output(directory, args, options),
|
|
2847
|
+
},
|
|
2848
|
+
place: null,
|
|
2849
|
+
temporaryDirectory: host.root,
|
|
2850
|
+
});
|
|
2851
|
+
expect(await apply(host, work.repo, recording)).toMatchObject({ status: 'applied' });
|
|
2852
|
+
expect(seen).toEqual([{ check: true, killTree: true, keepAtExit: false }, { check: false, killTree: true, keepAtExit: true }]);
|
|
2853
|
+
});
|
|
2854
|
+
|
|
2855
|
+
// A file that changes size while it is read, as an editor saving it at that moment makes it, is read
|
|
2856
|
+
// again; only one that keeps changing is not known. Here `stat` of the open file says one byte more
|
|
2857
|
+
// than it holds, the first `changing` times.
|
|
2858
|
+
const changingFiles = (target, changing) => {
|
|
2859
|
+
let opened = 0;
|
|
2860
|
+
return {
|
|
2861
|
+
...fs.promises,
|
|
2862
|
+
open: async (file, ...rest) => {
|
|
2863
|
+
const handle = await fs.promises.open(file, ...rest);
|
|
2864
|
+
if (file !== target) return handle;
|
|
2865
|
+
opened += 1;
|
|
2866
|
+
if (opened <= changing) {
|
|
2867
|
+
const stat = handle.stat.bind(handle);
|
|
2868
|
+
handle.stat = async (...args) => { const stats = await stat(...args); stats.size += 1; return stats; };
|
|
2869
|
+
}
|
|
2870
|
+
return handle;
|
|
2871
|
+
},
|
|
2872
|
+
opened: () => opened,
|
|
2873
|
+
};
|
|
2874
|
+
};
|
|
2875
|
+
const keptWork = async (host) => {
|
|
2876
|
+
const work = await roundsResult(host);
|
|
2877
|
+
work.round({ 'one.txt': numbered(10, { 3: 'round one' }) });
|
|
2878
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2879
|
+
work.round({ 'two.txt': numbered(10, { 5: 'round two' }) });
|
|
2880
|
+
return { work, target: path.join(fs.realpathSync(work.repo), 'one.txt') };
|
|
2881
|
+
};
|
|
2882
|
+
|
|
2883
|
+
it('reads a file again that changed while it was read, and answers from the read that held still', async () => {
|
|
2884
|
+
const host = createTestHost();
|
|
2885
|
+
const { work, target } = await keptWork(host);
|
|
2886
|
+
const files = changingFiles(target, 2);
|
|
2887
|
+
expect(await apply(host, work.repo, host.codeOut(null, { files }))).toMatchObject({ status: 'applied', appliedPaths: 1 });
|
|
2888
|
+
expect(files.opened()).toBe(3);
|
|
2889
|
+
expect(closed(work)).toBe(false);
|
|
2890
|
+
});
|
|
2891
|
+
|
|
2892
|
+
it('answers a file that keeps changing while it is read with changes_undecided, after a few reads', async () => {
|
|
2893
|
+
const host = createTestHost();
|
|
2894
|
+
const { work, target } = await keptWork(host);
|
|
2895
|
+
const files = changingFiles(target, Number.MAX_SAFE_INTEGER);
|
|
2896
|
+
const failure = await tryApply(host, work.repo, host.codeOut(null, { files }));
|
|
2897
|
+
expect(failure).toMatchObject({ code: 'changes_undecided', details: { cause: 'read_back_failed' } });
|
|
2898
|
+
expect(failure.message).toMatch(/one\.txt kept changing while it was read\./);
|
|
2899
|
+
expect(files.opened()).toBe(4);
|
|
2900
|
+
});
|
|
2901
|
+
|
|
2902
|
+
describe.skipIf(WIN)('a filter that hangs', () => {
|
|
2903
|
+
// A clean filter that notes its pid and then never ends; with `after`, only from its second run on
|
|
2904
|
+
// for a file, so the first read goes through and a later one hangs.
|
|
2905
|
+
const hangingFilter = (host, repo, { after = false } = {}) => {
|
|
2906
|
+
const pids = path.join(host.root, `filter-pids-${crypto.randomBytes(4).toString('hex')}`);
|
|
2907
|
+
const seen = path.join(host.root, `filter-seen-${crypto.randomBytes(4).toString('hex')}`);
|
|
2908
|
+
fs.mkdirSync(seen);
|
|
2909
|
+
const program = path.join(host.root, `hanging-clean-${crypto.randomBytes(4).toString('hex')}.sh`);
|
|
2910
|
+
const hang = `echo $$ >> '${pids}'; exec sleep 60`;
|
|
2911
|
+
fs.writeFileSync(program, after
|
|
2912
|
+
? `#!/bin/sh\nif [ -e '${seen}'/"$1" ]; then ${hang}; fi\ntouch '${seen}'/"$1"\ncat\n`
|
|
2913
|
+
: `#!/bin/sh\n${hang}\n`, { mode: 0o755 });
|
|
2914
|
+
host.addConfig(`[filter "hanging"]\n\tclean = ${forConfig(program)} %f`);
|
|
2915
|
+
fs.writeFileSync(path.join(repo, '.git', 'info', 'attributes'), '*.txt filter=hanging\n');
|
|
2916
|
+
return pids;
|
|
2917
|
+
};
|
|
2918
|
+
/** Code out whose git gives the command `picks` picks `ms` to run, as a deadline that came early. */
|
|
2919
|
+
const hurried = (host, picks, ms) => createCodeOut({
|
|
2920
|
+
git: {
|
|
2921
|
+
run: (directory, args, options) => host.git.run(directory, args, picks(args) ? { ...options, timeoutMs: ms } : options),
|
|
2922
|
+
output: (directory, args, options) => host.git.output(directory, args, picks(args) ? { ...options, timeoutMs: ms } : options),
|
|
2923
|
+
},
|
|
2924
|
+
place: null,
|
|
2925
|
+
temporaryDirectory: host.root,
|
|
2926
|
+
});
|
|
2927
|
+
|
|
2928
|
+
// The verifier's W1: five calls ran out of time on a clean filter that hangs, and each left one
|
|
2929
|
+
// filter behind. Now the whole tree of git ends with it.
|
|
2930
|
+
it('ends the filter with the read-back that ran out of time', async () => {
|
|
2931
|
+
const host = createTestHost();
|
|
2932
|
+
const work = await roundsResult(host);
|
|
2933
|
+
work.round({ 'one.txt': numbered(10, { 3: 'round one' }) });
|
|
2934
|
+
expect(await apply(host, work.repo)).toMatchObject({ status: 'applied' });
|
|
2935
|
+
fs.writeFileSync(path.join(work.repo, 'one.txt'), numbered(10, { 3: 'round one', 9: 'the user' }));
|
|
2936
|
+
work.round({ 'two.txt': numbered(10, { 5: 'round two' }) });
|
|
2937
|
+
const pids = hangingFilter(host, work.repo);
|
|
2938
|
+
expect(await describeState(host, work.repo, host.codeOut(null, { readBackTimeoutMs: 1500 }))).toMatchObject({ newPathsUndecided: true });
|
|
2939
|
+
expect(await leftAfter(pids)).toEqual([]);
|
|
2940
|
+
});
|
|
2941
|
+
|
|
2942
|
+
it('ends the filter with a dry run that ran out of time', async () => {
|
|
2943
|
+
const host = createTestHost();
|
|
2944
|
+
const work = await roundsResult(host);
|
|
2945
|
+
work.round({ 'one.txt': numbered(10, { 3: 'round one' }) });
|
|
2946
|
+
const pids = hangingFilter(host, work.repo);
|
|
2947
|
+
await expect(apply(host, work.repo, hurried(host, (args) => args.includes('--check'), 1500))).rejects.toMatchObject({ code: 'changes_undecided' });
|
|
2948
|
+
expect(await leftAfter(pids)).toEqual([]);
|
|
2949
|
+
});
|
|
2950
|
+
|
|
2951
|
+
it('ends the filter with an apply that ran out of time', async () => {
|
|
2952
|
+
const host = createTestHost();
|
|
2953
|
+
const work = await roundsResult(host);
|
|
2954
|
+
work.round({ 'one.txt': numbered(10, { 3: 'round one' }) });
|
|
2955
|
+
const pids = hangingFilter(host, work.repo, { after: true });
|
|
2956
|
+
await expect(apply(host, work.repo, hurried(host, (args) => args.includes('apply') && !args.includes('--check'), 1500))).rejects.toMatchObject({ code: 'command_timeout' });
|
|
2957
|
+
expect(await leftAfter(pids)).toEqual([]);
|
|
2958
|
+
});
|
|
2959
|
+
});
|
|
2960
|
+
});
|
|
2961
|
+
|
|
2962
|
+
describe('applyAsBranch', () => {
|
|
2963
|
+
it('makes the branch at the result without checking it out, and changes nothing else', async () => {
|
|
2964
|
+
const host = createTestHost();
|
|
2965
|
+
const { repo, g, result } = await hostResult(host, { edit: ordinaryEdit });
|
|
2966
|
+
const before = hostState(repo);
|
|
2967
|
+
expect(await host.codeOut().applyAsBranch({ repository: repo, spaceId: SPACE_ID, branch: 'space/work' })).toEqual({ branch: 'space/work', commit: result });
|
|
2968
|
+
expect(g(['rev-parse', 'refs/heads/space/work']).trim()).toBe(result);
|
|
2969
|
+
expect(g(['symbolic-ref', 'HEAD']).trim()).toBe('refs/heads/main');
|
|
2970
|
+
expect(unexpectedChanges(before, hostState(repo), { spaceIds: [SPACE_ID], codeOut: true }))
|
|
2971
|
+
.toEqual(['.git/logs/refs/heads/space', '.git/logs/refs/heads/space/work', '.git/refs/heads/space', '.git/refs/heads/space/work']);
|
|
2972
|
+
});
|
|
2973
|
+
|
|
2974
|
+
it('refuses an existing branch and leaves it where it was', async () => {
|
|
2975
|
+
const host = createTestHost();
|
|
2976
|
+
const { repo, g } = await hostResult(host, { edit: ordinaryEdit });
|
|
2977
|
+
const head = g(['rev-parse', 'HEAD']).trim();
|
|
2978
|
+
g(['branch', 'taken']);
|
|
2979
|
+
const before = hostState(repo);
|
|
2980
|
+
await expect(host.codeOut().applyAsBranch({ repository: repo, spaceId: SPACE_ID, branch: 'taken' })).rejects.toMatchObject({ code: 'branch_exists' });
|
|
2981
|
+
await expect(host.codeOut().applyAsBranch({ repository: repo, spaceId: SPACE_ID, branch: 'main' })).rejects.toMatchObject({ code: 'branch_exists' });
|
|
2982
|
+
expect(g(['rev-parse', 'refs/heads/taken']).trim()).toBe(head);
|
|
2983
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
2984
|
+
});
|
|
2985
|
+
|
|
2986
|
+
it.each([
|
|
2987
|
+
['feature/x', 'feature', 'feature'],
|
|
2988
|
+
['topic', 'topic/one', 'topic/one'],
|
|
2989
|
+
])('refuses %j beside the branch %j as an existing branch, and changes nothing', async (branch, existing, named) => {
|
|
2990
|
+
const host = createTestHost();
|
|
2991
|
+
const { repo, g } = await hostResult(host, { edit: ordinaryEdit });
|
|
2992
|
+
g(['branch', existing]);
|
|
2993
|
+
const before = hostState(repo);
|
|
2994
|
+
const failure = await host.codeOut().applyAsBranch({ repository: repo, spaceId: SPACE_ID, branch }).catch((error) => error);
|
|
2995
|
+
expect(failure).toMatchObject({ code: 'branch_exists', details: { branch: named } });
|
|
2996
|
+
expect(failure.message).toBe(`A branch named ${named} already exists, and git cannot keep a branch named ${branch} beside it. Choose another name; the existing branch was not changed.`);
|
|
2997
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
2998
|
+
});
|
|
2999
|
+
|
|
3000
|
+
it.each([
|
|
3001
|
+
[''], ['-starts-with-dash'], ['has space'], ['a..b'], ['ends.lock'], ['HEAD'], ['@{-1}'], ['tab\tinside'], ['line\nbreak'], ['trailing/'], [42], [null],
|
|
3002
|
+
])('refuses the branch name %j and changes nothing', async (branch) => {
|
|
3003
|
+
const host = createTestHost();
|
|
3004
|
+
const { repo, g } = await hostResult(host, { edit: ordinaryEdit });
|
|
3005
|
+
// A previous branch, so that `@{-1}` would expand to a real name.
|
|
3006
|
+
g(['checkout', '--quiet', '-b', 'previous']);
|
|
3007
|
+
g(['checkout', '--quiet', 'main']);
|
|
3008
|
+
const before = hostState(repo);
|
|
3009
|
+
await expect(host.codeOut().applyAsBranch({ repository: repo, spaceId: SPACE_ID, branch })).rejects.toMatchObject({ code: 'invalid_branch_name' });
|
|
3010
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
3011
|
+
});
|
|
3012
|
+
|
|
3013
|
+
it('refuses without a result', async () => {
|
|
3014
|
+
const host = createTestHost();
|
|
3015
|
+
const { repo } = makeBait(host);
|
|
3016
|
+
await expect(host.codeOut().applyAsBranch({ repository: repo, spaceId: SPACE_ID, branch: 'work' })).rejects.toMatchObject({ code: 'result_missing' });
|
|
3017
|
+
});
|
|
3018
|
+
|
|
3019
|
+
it('works in a SHA-256 repository and in a linked worktree, where the branch is seen from both', async () => {
|
|
3020
|
+
const host = createTestHost();
|
|
3021
|
+
const { g, result } = await hostResult(host, { objectFormat: 'sha256', edit: ordinaryEdit });
|
|
3022
|
+
const worktree = path.join(host.root, 'linked worktree');
|
|
3023
|
+
g(['worktree', 'add', '--quiet', '-b', 'feature', worktree]);
|
|
3024
|
+
await host.codeOut().applyAsBranch({ repository: worktree, spaceId: SPACE_ID, branch: 'from-the-space' });
|
|
3025
|
+
expect(g(['rev-parse', 'refs/heads/from-the-space']).trim()).toBe(result);
|
|
3026
|
+
expect(host.sh(worktree, ['symbolic-ref', 'HEAD']).trim()).toBe('refs/heads/feature');
|
|
3027
|
+
});
|
|
3028
|
+
});
|
|
3029
|
+
|
|
3030
|
+
/**
|
|
3031
|
+
* A space with the bait in it, through code in into the stand-in, and code out for it. `gi` runs git
|
|
3032
|
+
* in the space's repository as the agent would, `write` writes a file there.
|
|
3033
|
+
*/
|
|
3034
|
+
const spaceWithCode = async (host, { objectFormat = 'sha1', history = true, repository = null } = {}) => {
|
|
3035
|
+
const bait = makeBait(host, { objectFormat });
|
|
3036
|
+
const local = createLocalPlace(host, 'receive', SPACE_ID, { codeOut: true });
|
|
3037
|
+
const codeIn = host.codeIn(local.place);
|
|
3038
|
+
const came = await codeIn.bringCodeIn({ repository: repository ? repository(bait) : bait.repo, spaceId: SPACE_ID, timeoutMs: 60_000 });
|
|
3039
|
+
if (history) await codeIn.sendHistory({ repository: bait.repo, spaceId: SPACE_ID, spacePath: came.spacePath, base: came.base });
|
|
3040
|
+
const inside = local.local(came.spacePath);
|
|
3041
|
+
const gi = (args, options) => {
|
|
3042
|
+
const result = local.inside(['-C', inside, ...args], options);
|
|
3043
|
+
if (result.code !== 0) throw new Error(`git ${args.join(' ')} in the space: ${result.stderr}`);
|
|
3044
|
+
return result.stdout;
|
|
3045
|
+
};
|
|
3046
|
+
const write = (file, content) => {
|
|
3047
|
+
fs.mkdirSync(path.dirname(path.join(inside, file)), { recursive: true });
|
|
3048
|
+
fs.writeFileSync(path.join(inside, file), content);
|
|
3049
|
+
};
|
|
3050
|
+
const codeOut = host.codeOut(local.place);
|
|
3051
|
+
const request = (extra = {}) => ({ repository: bait.repo, spaceId: SPACE_ID, spacePath: came.spacePath, timeoutMs: 60_000, ...extra });
|
|
3052
|
+
return { ...bait, local, came, inside, gi, write, codeOut, request, out: (extra) => codeOut.bringCodeOut(request(extra)) };
|
|
3053
|
+
};
|
|
3054
|
+
|
|
3055
|
+
/** A commit in the space whose tree holds one entry named `name`, built without git's path checks, on top of HEAD. */
|
|
3056
|
+
const commitLiteralTree = (space, name) => {
|
|
3057
|
+
const blobId = space.gi(['hash-object', '-w', '--stdin'], { input: 'planted\n' }).trim();
|
|
3058
|
+
const tree = (entryName, mode, id) => space.gi(['hash-object', '-t', 'tree', '--literally', '-w', '--stdin'], {
|
|
3059
|
+
input: Buffer.concat([Buffer.from(`${mode} ${entryName}\0`), Buffer.from(id, 'hex')]),
|
|
3060
|
+
}).trim();
|
|
3061
|
+
// `.git/hooks/x` is three trees deep, the others are one entry.
|
|
3062
|
+
const parts = name.split('/');
|
|
3063
|
+
const nested = name.startsWith('.git/');
|
|
3064
|
+
let id = tree(nested ? parts.at(-1) : name, '100644', blobId);
|
|
3065
|
+
if (nested) {
|
|
3066
|
+
for (const part of parts.slice(0, -1).reverse()) id = tree(part, '40000', id);
|
|
3067
|
+
}
|
|
3068
|
+
const commit = space.gi(['commit-tree', id, '-p', 'HEAD', '-m', 'planted'], {}).trim();
|
|
3069
|
+
space.gi(['update-ref', 'HEAD', commit]);
|
|
3070
|
+
};
|
|
3071
|
+
|
|
3072
|
+
// Every hook that git on the host could run during code out, each writing a marker of its own name.
|
|
3073
|
+
const HOOK_NAMES = ['pre-push', 'post-index-change', 'reference-transaction', 'post-checkout', 'pre-commit', 'post-commit', 'pre-auto-gc', 'post-rewrite', 'push-to-checkout', 'pre-receive', 'post-receive', 'update', 'post-update', 'post-merge'];
|
|
3074
|
+
|
|
3075
|
+
describe.skipIf(WIN)('bringCodeOut from a local stand-in for a space', () => {
|
|
3076
|
+
it('brings the agent\'s commits and the uncommitted rest as one commit on top, and applies both ways', async () => {
|
|
3077
|
+
const host = createTestHost();
|
|
3078
|
+
const space = await spaceWithCode(host);
|
|
3079
|
+
const { repo, g, gi, write } = space;
|
|
3080
|
+
write('committed by the agent.txt', 'committed\n');
|
|
3081
|
+
gi(['add', 'committed by the agent.txt']);
|
|
3082
|
+
gi(['commit', '--quiet', '-m', 'the agent commits']);
|
|
3083
|
+
const agentHead = gi(['rev-parse', 'HEAD']).trim();
|
|
3084
|
+
write('tracked-to-edit.txt', 'one\ntwo staged\nthree unstaged\nfour, uncommitted in the space\n');
|
|
3085
|
+
write('untracked in the space.txt', 'untracked\n');
|
|
3086
|
+
const statusInside = gi(['status', '--porcelain=v1', '--untracked-files=all']);
|
|
3087
|
+
const before = hostState(repo);
|
|
3088
|
+
|
|
3089
|
+
const out = await space.out();
|
|
3090
|
+
expect(out).toMatchObject({ changedPaths: expect.any(Number), changedBytes: expect.any(Number) });
|
|
3091
|
+
expect(g(['rev-parse', RESULT]).trim()).toBe(out.result);
|
|
3092
|
+
expect(g(['rev-parse', `${out.result}^`]).trim()).toBe(agentHead);
|
|
3093
|
+
expect(g(['log', '-1', '--format=%an <%ae>%n%s', out.result]).trim()).toBe('OpenChamber <spaces@openchamber.invalid>\nopenchamber: uncommitted changes from the space');
|
|
3094
|
+
expect(blob(g, out.result, 'untracked in the space.txt')).toBe('untracked\n');
|
|
3095
|
+
// The agent's working tree and index are as they were.
|
|
3096
|
+
expect(gi(['status', '--porcelain=v1', '--untracked-files=all'])).toBe(statusInside);
|
|
3097
|
+
expect(gi(['rev-parse', 'HEAD']).trim()).toBe(agentHead);
|
|
3098
|
+
// Only new objects and the result ref: no FETCH_HEAD, no shallow file, no tag, no commit graph.
|
|
3099
|
+
expect(changedSince(before, repo)).toEqual([]);
|
|
3100
|
+
expect(leftFolders(host)).toEqual([]);
|
|
3101
|
+
// The changed paths against the start: the committed file, the edited one and the untracked one.
|
|
3102
|
+
expect(out.changedPaths).toBe(3);
|
|
3103
|
+
|
|
3104
|
+
const branchBefore = hostState(repo);
|
|
3105
|
+
await space.codeOut.applyAsBranch({ repository: repo, spaceId: SPACE_ID, branch: 'from-the-space' });
|
|
3106
|
+
expect(g(['rev-parse', 'refs/heads/from-the-space']).trim()).toBe(out.result);
|
|
3107
|
+
expect(unexpectedChanges(branchBefore, hostState(repo), { spaceIds: [SPACE_ID], codeOut: true }))
|
|
3108
|
+
.toEqual(['.git/logs/refs/heads/from-the-space', '.git/refs/heads/from-the-space']);
|
|
3109
|
+
|
|
3110
|
+
expect(await space.codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied', appliedPaths: 3, remembered: true });
|
|
3111
|
+
expect(workingTreeAs(host, repo, out.result)).toBe(g(['rev-parse', `${out.result}^{tree}`]).trim());
|
|
3112
|
+
expect(g(['symbolic-ref', 'HEAD']).trim()).toBe('refs/heads/main');
|
|
3113
|
+
});
|
|
3114
|
+
|
|
3115
|
+
it('adds no commit when nothing is uncommitted, so the result is the agent\'s HEAD', async () => {
|
|
3116
|
+
const host = createTestHost();
|
|
3117
|
+
const space = await spaceWithCode(host);
|
|
3118
|
+
space.gi(['add', '--all']);
|
|
3119
|
+
space.gi(['commit', '--quiet', '-m', 'the agent commits everything']);
|
|
3120
|
+
const out = await space.out();
|
|
3121
|
+
expect(out.result).toBe(space.gi(['rev-parse', 'HEAD']).trim());
|
|
3122
|
+
expect(await space.codeOut.applyAsChanges({ repository: space.repo, spaceId: SPACE_ID })).toEqual({ status: 'nothing_to_apply' });
|
|
3123
|
+
});
|
|
3124
|
+
|
|
3125
|
+
it('comes out of a space whose history has not arrived, and the user\'s repository stays complete', async () => {
|
|
3126
|
+
const host = createTestHost();
|
|
3127
|
+
const space = await spaceWithCode(host, { history: false });
|
|
3128
|
+
expect(space.gi(['rev-parse', '--is-shallow-repository']).trim()).toBe('true');
|
|
3129
|
+
space.write('from a shallow space.txt', 'shallow\n');
|
|
3130
|
+
const before = hostState(space.repo);
|
|
3131
|
+
const out = await space.out();
|
|
3132
|
+
expect(blob(space.g, out.result, 'from a shallow space.txt')).toBe('shallow\n');
|
|
3133
|
+
expect(fs.existsSync(path.join(space.repo, '.git', 'shallow'))).toBe(false);
|
|
3134
|
+
expect(space.g(['rev-parse', '--is-shallow-repository']).trim()).toBe('false');
|
|
3135
|
+
expect(changedSince(before, space.repo)).toEqual([]);
|
|
3136
|
+
});
|
|
3137
|
+
|
|
3138
|
+
it('refuses a result whose history claims a shallow root, instead of taking git\'s silent exit 0, and never makes the repository shallow', async () => {
|
|
3139
|
+
const host = createTestHost();
|
|
3140
|
+
const space = await spaceWithCode(host);
|
|
3141
|
+
space.write('c1.txt', 'c1\n');
|
|
3142
|
+
space.gi(['add', 'c1.txt']);
|
|
3143
|
+
space.gi(['commit', '--quiet', '-m', 'c1']);
|
|
3144
|
+
const c1 = space.gi(['rev-parse', 'HEAD']).trim();
|
|
3145
|
+
space.write('c2.txt', 'c2\n');
|
|
3146
|
+
space.gi(['add', 'c2.txt']);
|
|
3147
|
+
space.gi(['commit', '--quiet', '-m', 'c2']);
|
|
3148
|
+
fs.writeFileSync(path.join(space.inside, '.git', 'shallow'), `${c1}\n`);
|
|
3149
|
+
const before = hostState(space.repo);
|
|
3150
|
+
const failure = await space.out().catch((error) => error);
|
|
3151
|
+
expect(failure).toMatchObject({ code: 'result_ref_missing', details: { step: 'promote the result' } });
|
|
3152
|
+
expect(space.g(['for-each-ref', RESULT])).toBe('');
|
|
3153
|
+
expect(fs.existsSync(path.join(space.repo, '.git', 'shallow'))).toBe(false);
|
|
3154
|
+
expect(changedSince(before, space.repo)).toEqual([]);
|
|
3155
|
+
expect(leftFolders(host)).toEqual([]);
|
|
3156
|
+
});
|
|
3157
|
+
|
|
3158
|
+
it('keeps a later result: the ref moves, within this space\'s namespace only', async () => {
|
|
3159
|
+
const host = createTestHost();
|
|
3160
|
+
const space = await spaceWithCode(host);
|
|
3161
|
+
space.write('first.txt', 'first\n');
|
|
3162
|
+
const first = await space.out();
|
|
3163
|
+
space.write('second.txt', 'second\n');
|
|
3164
|
+
const second = await space.out();
|
|
3165
|
+
expect(second.result).not.toBe(first.result);
|
|
3166
|
+
expect(space.g(['for-each-ref', '--format=%(refname) %(objectname)', 'refs/openchamber/'])).toBe(`${RESULT} ${second.result}\n${START} ${space.came.start}\n`);
|
|
3167
|
+
// removeSpaceRefs of code in takes the result with the start.
|
|
3168
|
+
await space.codeOut.applyAsChanges({ repository: space.repo, spaceId: SPACE_ID });
|
|
3169
|
+
space.g(['update-ref', CLOSED, second.result]);
|
|
3170
|
+
space.g(['update-ref', APPLYING, second.result]);
|
|
3171
|
+
expect(await host.codeIn().removeSpaceRefs({ repository: space.repo, spaceId: SPACE_ID })).toEqual([APPLIED, APPLIED_FROM, APPLIED_HEAD, APPLYING, CLOSED, RESULT, START]);
|
|
3172
|
+
expect(space.g(['for-each-ref', 'refs/openchamber/'])).toBe('');
|
|
3173
|
+
});
|
|
3174
|
+
|
|
3175
|
+
// Git's object checks on the quarantine fetch refuse each of these. The test asks for the refusal
|
|
3176
|
+
// at the quarantine: the promote checks the same objects, and a test that let it catch them there
|
|
3177
|
+
// would pass with the quarantine's checks gone.
|
|
3178
|
+
it.each(['.git', '.GIT', '.Git', 'git~1', '.git ', '.git.', '..', '.', 'a/../../x', '.git/hooks/x'])('refuses a tree entry named %j at the quarantine, and nothing reaches the repository', async (name) => {
|
|
3179
|
+
const host = createTestHost();
|
|
3180
|
+
const space = await spaceWithCode(host);
|
|
3181
|
+
commitLiteralTree(space, name);
|
|
3182
|
+
const before = hostState(space.repo);
|
|
3183
|
+
const failure = await space.out().catch((error) => error);
|
|
3184
|
+
expect(failure).toMatchObject({ code: 'code_out_failed', details: { step: 'fetch into the quarantine' } });
|
|
3185
|
+
expect(space.g(['for-each-ref', RESULT])).toBe('');
|
|
3186
|
+
expect(anyChangeSince(before, space.repo)).toEqual([]);
|
|
3187
|
+
expect(leftFolders(host)).toEqual([]);
|
|
3188
|
+
});
|
|
3189
|
+
|
|
3190
|
+
// The positive control of the test above: a tree built the same way with an ordinary name comes out.
|
|
3191
|
+
it('brings a tree built the same way with an ordinary name out', async () => {
|
|
3192
|
+
const host = createTestHost();
|
|
3193
|
+
const space = await spaceWithCode(host);
|
|
3194
|
+
commitLiteralTree(space, 'ordinary.txt');
|
|
3195
|
+
const out = await space.out();
|
|
3196
|
+
expect(blob(space.g, `${out.result}^`, 'ordinary.txt')).toBe('planted\n');
|
|
3197
|
+
});
|
|
3198
|
+
|
|
3199
|
+
it('takes no tag and no ref of the space but its result', async () => {
|
|
3200
|
+
const host = createTestHost();
|
|
3201
|
+
const space = await spaceWithCode(host);
|
|
3202
|
+
space.write('tagged.txt', 'tagged\n');
|
|
3203
|
+
space.gi(['add', 'tagged.txt']);
|
|
3204
|
+
space.gi(['commit', '--quiet', '-m', 'tagged']);
|
|
3205
|
+
space.gi(['tag', 'planted-light']);
|
|
3206
|
+
space.gi(['tag', '-a', '-m', 'annotated', 'planted-annotated']);
|
|
3207
|
+
space.gi(['update-ref', 'refs/heads/planted-branch', 'HEAD']);
|
|
3208
|
+
space.gi(['update-ref', `refs/openchamber/spaces/${OTHER_SPACE_ID}/start`, 'HEAD']);
|
|
3209
|
+
// The control: an ordinary fetch of the same result from the space does follow its tags.
|
|
3210
|
+
const control = path.join(host.root, 'tag control');
|
|
3211
|
+
host.sh(host.root, ['init', '--quiet', control]);
|
|
3212
|
+
host.sh(control, ['fetch', '--quiet', space.inside, 'HEAD:refs/heads/fetched']);
|
|
3213
|
+
expect(host.sh(control, ['tag', '--list']).split('\n').filter(Boolean).sort()).toEqual(['planted-annotated', 'planted-light']);
|
|
3214
|
+
|
|
3215
|
+
const refs = () => space.g(['for-each-ref', '--format=%(refname)']).split('\n').filter(Boolean);
|
|
3216
|
+
const refsBefore = refs();
|
|
3217
|
+
await space.out();
|
|
3218
|
+
expect(refs()).toEqual([...refsBefore, RESULT].sort());
|
|
3219
|
+
});
|
|
3220
|
+
|
|
3221
|
+
it.each([
|
|
3222
|
+
['refuses', 'refuse', null, 'refused by the space'],
|
|
3223
|
+
['prints far more than a fetch does', 'flood', 'command_output_too_large', ''],
|
|
3224
|
+
])('changes nothing when the space %s', async (_, behaviour, cause, printed) => {
|
|
3225
|
+
const host = createTestHost();
|
|
3226
|
+
const space = await spaceWithCode(host);
|
|
3227
|
+
space.local.setBehaviour(behaviour);
|
|
3228
|
+
const before = hostState(space.repo);
|
|
3229
|
+
const failure = await space.out({ timeoutMs: 20_000 }).catch((error) => error);
|
|
3230
|
+
expect(failure).toMatchObject({ code: 'code_out_failed', details: { step: 'fetch into the quarantine', cause } });
|
|
3231
|
+
// The space side really ran, with the command the host wrote out.
|
|
3232
|
+
expect(space.local.lastCommand().slice(4)).toEqual(['/usr/bin/git', 'upload-pack', space.came.spacePath]);
|
|
3233
|
+
expect(failure.message).toContain(printed);
|
|
3234
|
+
expect(anyChangeSince(before, space.repo)).toEqual([]);
|
|
3235
|
+
expect(leftFolders(host)).toEqual([]);
|
|
3236
|
+
});
|
|
3237
|
+
|
|
3238
|
+
it('ends a space that never answers at the host timeout, and leaves no host process and no quarantine behind', async () => {
|
|
3239
|
+
const host = createTestHost();
|
|
3240
|
+
const space = await spaceWithCode(host);
|
|
3241
|
+
space.local.setBehaviour('hang');
|
|
3242
|
+
const before = hostState(space.repo);
|
|
3243
|
+
const failure = await space.out({ timeoutMs: 3000 }).catch((error) => error);
|
|
3244
|
+
expect(failure).toMatchObject({ code: 'code_out_failed', details: { step: 'fetch into the quarantine', cause: 'command_timeout' } });
|
|
3245
|
+
const pid = space.local.hangPid();
|
|
3246
|
+
expect(Number.isInteger(pid) && pid > 1).toBe(true);
|
|
3247
|
+
const alive = () => {
|
|
3248
|
+
try {
|
|
3249
|
+
process.kill(pid, 0);
|
|
3250
|
+
return true;
|
|
3251
|
+
} catch {
|
|
3252
|
+
return false;
|
|
3253
|
+
}
|
|
3254
|
+
};
|
|
3255
|
+
for (let attempt = 0; attempt < 50 && alive(); attempt += 1) {
|
|
3256
|
+
await new Promise((resolve) => { setTimeout(resolve, 100); });
|
|
3257
|
+
}
|
|
3258
|
+
const survived = alive();
|
|
3259
|
+
if (survived) process.kill(pid, 'SIGKILL');
|
|
3260
|
+
expect(survived).toBe(false);
|
|
3261
|
+
expect(anyChangeSince(before, space.repo)).toEqual([]);
|
|
3262
|
+
expect(leftFolders(host)).toEqual([]);
|
|
3263
|
+
});
|
|
3264
|
+
|
|
3265
|
+
// The stand-in sends at about 3 MB/s, so eight random megabytes take close to three seconds. The
|
|
3266
|
+
// cap is one: the fetch must be stopped in the middle, which the upload-pack in the space shows by
|
|
3267
|
+
// never reporting how it ended.
|
|
3268
|
+
it('stops a transfer that passes the size cap in the middle, and nothing reaches the repository', async () => {
|
|
3269
|
+
const host = createTestHost();
|
|
3270
|
+
const space = await spaceWithCode(host);
|
|
3271
|
+
space.write('large.bin', crypto.randomBytes(8 * 1024 * 1024));
|
|
3272
|
+
space.local.setBehaviour('slow');
|
|
3273
|
+
const before = hostState(space.repo);
|
|
3274
|
+
const failure = await space.out({ maxTransferBytes: 1024 * 1024 }).catch((error) => error);
|
|
3275
|
+
expect(failure).toMatchObject({ code: 'result_transfer_too_large', details: { step: 'fetch into the quarantine' } });
|
|
3276
|
+
expect(space.local.lastCommand()[5]).toBe('upload-pack');
|
|
3277
|
+
expect(space.local.uploadPackExit()).toBeNull();
|
|
3278
|
+
expect(anyChangeSince(before, space.repo)).toEqual([]);
|
|
3279
|
+
expect(leftFolders(host)).toEqual([]);
|
|
3280
|
+
// The control: the same space with room for it comes out, and its upload-pack ends normally.
|
|
3281
|
+
const out = await space.out();
|
|
3282
|
+
expect(out.changedBytes).toBe(8 * 1024 * 1024);
|
|
3283
|
+
expect(space.local.uploadPackExit()).toBe('0');
|
|
3284
|
+
});
|
|
3285
|
+
|
|
3286
|
+
// The space can move its own result ref between the snapshot and the fetch: a reviewer did it live
|
|
3287
|
+
// with a loop inside. What the host reports must describe the object it delivered, so the fetch
|
|
3288
|
+
// takes the commit the snapshot said it made, and nothing else. Here the swap happens at the one
|
|
3289
|
+
// moment that matters, the moment the snapshot call is over.
|
|
3290
|
+
it('refuses a result the space swapped for another between the snapshot and the fetch', async () => {
|
|
3291
|
+
const host = createTestHost();
|
|
3292
|
+
const space = await spaceWithCode(host);
|
|
3293
|
+
space.write('conflict.txt', 'the file the warnings would be about\n');
|
|
3294
|
+
// A commit of the agent's with a tree of its own, which it puts in place of the snapshot.
|
|
3295
|
+
const blobId = space.gi(['hash-object', '-w', '--stdin'], { input: 'swapped\n' }).trim();
|
|
3296
|
+
const tree = space.gi(['mktree'], { input: `100644 blob ${blobId}\tonly-this-file.txt\n` }).trim();
|
|
3297
|
+
const swapped = space.gi(['commit-tree', tree, '-p', 'HEAD', '-m', 'swapped']).trim();
|
|
3298
|
+
let swap = true;
|
|
3299
|
+
const swapping = {
|
|
3300
|
+
execArgv: (...args) => space.local.place.execArgv(...args),
|
|
3301
|
+
exec: async (...args) => {
|
|
3302
|
+
const result = await space.local.place.exec(...args);
|
|
3303
|
+
if (swap) space.gi(['update-ref', 'refs/openchamber/result', swapped]);
|
|
3304
|
+
return result;
|
|
3305
|
+
},
|
|
3306
|
+
};
|
|
3307
|
+
const codeOut = createCodeOut({ git: host.git, place: swapping, temporaryDirectory: host.root });
|
|
3308
|
+
const before = hostState(space.repo);
|
|
3309
|
+
|
|
3310
|
+
const failure = await codeOut.bringCodeOut(space.request()).catch((error) => error);
|
|
3311
|
+
expect(failure).toMatchObject({ code: 'result_ref_missing', details: { step: 'fetch into the quarantine' } });
|
|
3312
|
+
expect(space.g(['for-each-ref', RESULT])).toBe('');
|
|
3313
|
+
expect(anyChangeSince(before, space.repo)).toEqual([]);
|
|
3314
|
+
// The control: without the swap the same place brings the result out, and it holds the file the
|
|
3315
|
+
// warnings would name, not the one the swap offered.
|
|
3316
|
+
swap = false;
|
|
3317
|
+
const out = await codeOut.bringCodeOut(space.request());
|
|
3318
|
+
expect(blob(space.g, out.result, 'conflict.txt')).toBe('the file the warnings would be about\n');
|
|
3319
|
+
expect(space.g(['ls-tree', '--name-only', out.result]).split('\n')).not.toContain('only-this-file.txt');
|
|
3320
|
+
});
|
|
3321
|
+
|
|
3322
|
+
it('refuses one object that is not a file and is larger than an object may be', async () => {
|
|
3323
|
+
const host = createTestHost();
|
|
3324
|
+
const space = await spaceWithCode(host);
|
|
3325
|
+
const message = path.join(host.root, 'very-long-message.txt');
|
|
3326
|
+
fs.writeFileSync(message, 'a line that says nothing, many times over\n'.repeat(130_000));
|
|
3327
|
+
expect(fs.statSync(message).size).toBeGreaterThan(5 * 1024 * 1024);
|
|
3328
|
+
space.gi(['commit', '--quiet', '--allow-empty', '-F', message]);
|
|
3329
|
+
const before = hostState(space.repo);
|
|
3330
|
+
// Far more room than the message needs: the cap of one object is what refuses it.
|
|
3331
|
+
const failure = await space.out({ maxChangedBytes: 512 * 1024 * 1024 }).catch((error) => error);
|
|
3332
|
+
expect(failure).toMatchObject({ code: 'result_too_large', details: { objectType: 'commit' } });
|
|
3333
|
+
expect(failure.details.objectBytes).toBeGreaterThan(5 * 1024 * 1024);
|
|
3334
|
+
expect(anyChangeSince(before, space.repo)).toEqual([]);
|
|
3335
|
+
// The control: without that commit in its history, a message under the cap comes out.
|
|
3336
|
+
space.gi(['update-ref', 'HEAD', space.gi(['rev-parse', 'HEAD~1']).trim()]);
|
|
3337
|
+
fs.writeFileSync(message, 'a line that says nothing, many times over\n'.repeat(20_000));
|
|
3338
|
+
space.gi(['commit', '--quiet', '--allow-empty', '-F', message]);
|
|
3339
|
+
expect(await space.out({ maxChangedBytes: 512 * 1024 * 1024 })).toMatchObject({ result: expect.any(String) });
|
|
3340
|
+
}, 60_000);
|
|
3341
|
+
|
|
3342
|
+
// A deletion brings no object at all, and both `diff-tree` and `apply` read the file that goes.
|
|
3343
|
+
it('charges what a deletion costs, and refuses to apply what is too large to patch', async () => {
|
|
3344
|
+
const host = createTestHost();
|
|
3345
|
+
const space = await spaceWithCode(host, {
|
|
3346
|
+
repository: (bait) => {
|
|
3347
|
+
fs.writeFileSync(path.join(bait.repo, 'large.bin'), crypto.randomBytes(4 * 1024 * 1024));
|
|
3348
|
+
bait.g(['add', 'large.bin']);
|
|
3349
|
+
bait.g(['commit', '--quiet', '-m', 'a large file of the user']);
|
|
3350
|
+
return bait.repo;
|
|
3351
|
+
},
|
|
3352
|
+
});
|
|
3353
|
+
space.gi(['rm', '--quiet', 'large.bin']);
|
|
3354
|
+
const out = await space.out();
|
|
3355
|
+
expect(out.changedBytes).toBeGreaterThanOrEqual(4 * 1024 * 1024);
|
|
3356
|
+
expect(out.newBytes).toBeLessThan(4096);
|
|
3357
|
+
const before = hostState(space.repo);
|
|
3358
|
+
const failure = await space.codeOut.applyAsChanges({ repository: space.repo, spaceId: SPACE_ID, maxChangedBytes: 1024 * 1024 }).catch((error) => error);
|
|
3359
|
+
expect(failure).toMatchObject({ code: 'changes_too_large' });
|
|
3360
|
+
expect(failure.message).toMatch(/as a branch/);
|
|
3361
|
+
expect(anyChangeSince(before, space.repo)).toEqual([]);
|
|
3362
|
+
// The control: with room for it the deletion applies.
|
|
3363
|
+
expect(await space.codeOut.applyAsChanges({ repository: space.repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied' });
|
|
3364
|
+
expect(fs.existsSync(path.join(space.repo, 'large.bin'))).toBe(false);
|
|
3365
|
+
});
|
|
3366
|
+
|
|
3367
|
+
it('refuses to apply many copies of a file the user already has, which brings almost no object', async () => {
|
|
3368
|
+
const host = createTestHost();
|
|
3369
|
+
const space = await spaceWithCode(host, {
|
|
3370
|
+
repository: (bait) => {
|
|
3371
|
+
fs.writeFileSync(path.join(bait.repo, 'host-large.bin'), crypto.randomBytes(1024 * 1024));
|
|
3372
|
+
bait.g(['add', 'host-large.bin']);
|
|
3373
|
+
bait.g(['commit', '--quiet', '-m', 'a large file of the user']);
|
|
3374
|
+
return bait.repo;
|
|
3375
|
+
},
|
|
3376
|
+
});
|
|
3377
|
+
for (let copy = 0; copy < 12; copy += 1) fs.copyFileSync(path.join(space.inside, 'host-large.bin'), path.join(space.inside, `copy-${copy}.bin`));
|
|
3378
|
+
const out = await space.out();
|
|
3379
|
+
expect(out.changedBytes).toBeGreaterThanOrEqual(12 * 1024 * 1024);
|
|
3380
|
+
const before = hostState(space.repo);
|
|
3381
|
+
await expect(space.codeOut.applyAsChanges({ repository: space.repo, spaceId: SPACE_ID, maxChangedBytes: 8 * 1024 * 1024 }))
|
|
3382
|
+
.rejects.toMatchObject({ code: 'changes_too_large' });
|
|
3383
|
+
expect(anyChangeSince(before, space.repo)).toEqual([]);
|
|
3384
|
+
expect(await space.codeOut.applyAsChanges({ repository: space.repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied' });
|
|
3385
|
+
});
|
|
3386
|
+
|
|
3387
|
+
// A hook of the agent's prints during the snapshot. Its output must not be read as part of the report.
|
|
3388
|
+
// An ordinary-path check: the work still comes out while a hook of the agent's prints. The proofs
|
|
3389
|
+
// of the report's channel are the test just below and the live test through /proc.
|
|
3390
|
+
it('brings the work out while a hook of the agent prints during the snapshot', async () => {
|
|
3391
|
+
const host = createTestHost();
|
|
3392
|
+
const space = await spaceWithCode(host);
|
|
3393
|
+
const noisy = path.join(space.inside, '.git', 'hooks', 'reference-transaction');
|
|
3394
|
+
const marker = path.join(space.inside, 'hook-ran');
|
|
3395
|
+
fs.mkdirSync(path.dirname(noisy), { recursive: true });
|
|
3396
|
+
fs.writeFileSync(noisy, [
|
|
3397
|
+
'#!/bin/sh',
|
|
3398
|
+
`: > '${marker}'`,
|
|
3399
|
+
'printf "100644 0000000000000000000000000000000000000000 1\\tinvented-by-a-hook.txt\\0"',
|
|
3400
|
+
'printf "and a line of its own\\n"',
|
|
3401
|
+
'exit 0',
|
|
3402
|
+
'',
|
|
3403
|
+
].join('\n'), { mode: 0o755 });
|
|
3404
|
+
space.write('real.txt', 'real\n');
|
|
3405
|
+
const out = await space.out();
|
|
3406
|
+
// The control: the hook really ran and really printed while the snapshot was being made.
|
|
3407
|
+
expect(fs.existsSync(marker)).toBe(true);
|
|
3408
|
+
expect(out.unmerged).toEqual({ count: 0, paths: [] });
|
|
3409
|
+
expect(blob(space.g, out.result, 'real.txt')).toBe('real\n');
|
|
3410
|
+
});
|
|
3411
|
+
|
|
3412
|
+
// A hook of the agent's inherits whatever its git has open. It moves the result ref to a commit of
|
|
3413
|
+
// its own a moment after the snapshot wrote it, and writes that commit's id on descriptor 3 first,
|
|
3414
|
+
// where the report goes. Every command of the snapshot runs with descriptor 3 closed, so the id the
|
|
3415
|
+
// host reads is the snapshot's, and the moved ref is refused rather than taken. This is the plain
|
|
3416
|
+
// route through the descriptor the hook inherits, and no more: the channel is not private, and on
|
|
3417
|
+
// Linux a process of the agent's can still open the shell's copy of it through /proc. The host
|
|
3418
|
+
// treats the report as data from the space either way, see the snapshot script.
|
|
3419
|
+
it('does not let a hook write into the report through the descriptor 3 it inherits', async () => {
|
|
3420
|
+
const host = createTestHost();
|
|
3421
|
+
const space = await spaceWithCode(host);
|
|
3422
|
+
const blobId = space.gi(['hash-object', '-w', '--stdin'], { input: 'planted\n' }).trim();
|
|
3423
|
+
const tree = space.gi(['mktree'], { input: `100644 blob ${blobId}\tplanted.txt\n` }).trim();
|
|
3424
|
+
const planted = space.gi(['commit-tree', tree, '-p', 'HEAD', '-m', 'planted']).trim();
|
|
3425
|
+
const hook = path.join(space.inside, '.git', 'hooks', 'reference-transaction');
|
|
3426
|
+
const tried = path.join(space.inside, '.git', 'hook-tried-descriptor-3');
|
|
3427
|
+
fs.mkdirSync(path.dirname(hook), { recursive: true });
|
|
3428
|
+
fs.writeFileSync(hook, [
|
|
3429
|
+
'#!/bin/sh',
|
|
3430
|
+
'[ "$1" = committed ] || exit 0',
|
|
3431
|
+
`[ -e '${tried}' ] && exit 0`,
|
|
3432
|
+
`: > '${tried}'`,
|
|
3433
|
+
`printf '%s\\n' ${planted} >&3`,
|
|
3434
|
+
`( sleep 0.3; git update-ref refs/openchamber/result ${planted} ) &`,
|
|
3435
|
+
'exit 0',
|
|
3436
|
+
'',
|
|
3437
|
+
].join('\n'), { mode: 0o755 });
|
|
3438
|
+
space.write('real.txt', 'real\n');
|
|
3439
|
+
const before = hostState(space.repo);
|
|
3440
|
+
const failure = await space.out().catch((error) => error);
|
|
3441
|
+
// The control: the hook ran on the snapshot's own ref write.
|
|
3442
|
+
expect(fs.existsSync(tried)).toBe(true);
|
|
3443
|
+
expect(failure).toMatchObject({ code: 'result_ref_missing', details: { step: 'fetch into the quarantine' } });
|
|
3444
|
+
expect(anyChangeSince(before, space.repo)).toEqual([]);
|
|
3445
|
+
});
|
|
3446
|
+
|
|
3447
|
+
it.skipIf(WIN)('counts a path the space names with a control character, and shows it redacted', async () => {
|
|
3448
|
+
const host = createTestHost();
|
|
3449
|
+
const space = await spaceWithCode(host);
|
|
3450
|
+
// A repository of the agent's whose name holds a line break, and a conflict in a file named the same way.
|
|
3451
|
+
space.gi(['init', '--quiet', 'made\nby the agent']);
|
|
3452
|
+
space.write('made\nby the agent/inner.txt', 'inner\n');
|
|
3453
|
+
const nested = ['-C', path.join(space.inside, 'made\nby the agent')];
|
|
3454
|
+
space.gi([...nested, 'add', 'inner.txt']);
|
|
3455
|
+
space.gi([...nested, '-c', 'user.name=Agent', '-c', 'user.email=agent@example.invalid', 'commit', '--quiet', '-m', 'inner']);
|
|
3456
|
+
space.write('conflicted\nname.txt', 'one\n');
|
|
3457
|
+
space.gi(['add', '--', 'conflicted\nname.txt']);
|
|
3458
|
+
space.gi(['commit', '--quiet', '-m', 'the agent starts']);
|
|
3459
|
+
space.gi(['checkout', '--quiet', '-b', 'side']);
|
|
3460
|
+
space.write('conflicted\nname.txt', 'side\n');
|
|
3461
|
+
space.gi(['commit', '--quiet', '-am', 'side']);
|
|
3462
|
+
space.gi(['checkout', '--quiet', 'main']);
|
|
3463
|
+
space.write('conflicted\nname.txt', 'main\n');
|
|
3464
|
+
space.gi(['commit', '--quiet', '-am', 'main']);
|
|
3465
|
+
expect(space.local.inside(['-C', space.inside, 'merge', 'side']).code).not.toBe(0);
|
|
3466
|
+
|
|
3467
|
+
const out = await space.out();
|
|
3468
|
+
expect(out.unmerged.count).toBe(1);
|
|
3469
|
+
expect(out.unmerged.paths).toEqual(['conflicted?name.txt']);
|
|
3470
|
+
expect(out.nestedRepositories.count).toBe(1);
|
|
3471
|
+
expect(out.nestedRepositories.paths).toEqual(['made?by the agent']);
|
|
3472
|
+
});
|
|
3473
|
+
|
|
3474
|
+
it('reports what it applied: the nested repositories and the conflict markers in it', async () => {
|
|
3475
|
+
const host = createTestHost();
|
|
3476
|
+
const space = await spaceWithCode(host);
|
|
3477
|
+
space.gi(['init', '--quiet', 'made-by-the-agent']);
|
|
3478
|
+
space.write('made-by-the-agent/inner.txt', 'inner\n');
|
|
3479
|
+
const nested = ['-C', path.join(space.inside, 'made-by-the-agent')];
|
|
3480
|
+
space.gi([...nested, 'add', 'inner.txt']);
|
|
3481
|
+
space.gi([...nested, '-c', 'user.name=Agent', '-c', 'user.email=agent@example.invalid', 'commit', '--quiet', '-m', 'inner']);
|
|
3482
|
+
space.write('README.md', '<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> side\n');
|
|
3483
|
+
await space.out();
|
|
3484
|
+
const applied = await space.codeOut.applyAsChanges({ repository: space.repo, spaceId: SPACE_ID });
|
|
3485
|
+
expect(applied).toMatchObject({
|
|
3486
|
+
status: 'applied',
|
|
3487
|
+
nestedRepositories: { count: 1, paths: ['made-by-the-agent'] },
|
|
3488
|
+
conflicted: { count: 1, paths: ['README.md'] },
|
|
3489
|
+
});
|
|
3490
|
+
});
|
|
3491
|
+
|
|
3492
|
+
it.each([
|
|
3493
|
+
['a file of zeros that compresses to almost nothing', (space) => space.write('zeros.bin', Buffer.alloc(24 * 1024 * 1024))],
|
|
3494
|
+
['a large file committed and deleted again', (space) => {
|
|
3495
|
+
space.write('zeros.bin', Buffer.alloc(24 * 1024 * 1024));
|
|
3496
|
+
space.gi(['add', 'zeros.bin']);
|
|
3497
|
+
space.gi(['commit', '--quiet', '-m', 'large']);
|
|
3498
|
+
space.gi(['rm', '--quiet', 'zeros.bin']);
|
|
3499
|
+
space.gi(['commit', '--quiet', '-m', 'gone again']);
|
|
3500
|
+
}],
|
|
3501
|
+
])('refuses %s over the changed-bytes cap before it reaches the repository', async (_, make) => {
|
|
3502
|
+
const host = createTestHost();
|
|
3503
|
+
const space = await spaceWithCode(host, {
|
|
3504
|
+
repository: (bait) => {
|
|
3505
|
+
fs.writeFileSync(path.join(bait.repo, 'host-large.bin'), crypto.randomBytes(1024 * 1024));
|
|
3506
|
+
bait.g(['add', 'host-large.bin']);
|
|
3507
|
+
bait.g(['commit', '--quiet', '-m', 'a large file of the user']);
|
|
3508
|
+
return bait.repo;
|
|
3509
|
+
},
|
|
3510
|
+
});
|
|
3511
|
+
make(space);
|
|
3512
|
+
const before = hostState(space.repo);
|
|
3513
|
+
const failure = await space.out({ maxChangedBytes: 8 * 1024 * 1024 }).catch((error) => error);
|
|
3514
|
+
expect(failure).toMatchObject({ code: 'result_too_large', details: { step: 'measure the result' } });
|
|
3515
|
+
expect(anyChangeSince(before, space.repo)).toEqual([]);
|
|
3516
|
+
// The control: with room for it, the same result comes out.
|
|
3517
|
+
const out = await space.out({ maxChangedBytes: 64 * 1024 * 1024 });
|
|
3518
|
+
expect(space.g(['rev-parse', RESULT]).trim()).toBe(out.result);
|
|
3519
|
+
});
|
|
3520
|
+
|
|
3521
|
+
// A commit message and a tree object weigh as much as a file, and repetitive text travels in a pack
|
|
3522
|
+
// of a few kilobytes, so neither cap sees it on the way. Both are sized as every other new object.
|
|
3523
|
+
it.each([
|
|
3524
|
+
['a huge commit message', (space, host) => {
|
|
3525
|
+
const message = path.join(host.root, 'huge-message.txt');
|
|
3526
|
+
fs.writeFileSync(message, 'a line that says nothing, many times over\n'.repeat(26_000));
|
|
3527
|
+
expect(fs.statSync(message).size).toBeGreaterThan(1024 * 1024);
|
|
3528
|
+
space.gi(['commit', '--quiet', '--allow-empty', '-F', message]);
|
|
3529
|
+
}],
|
|
3530
|
+
['a tree object with forty thousand entries', (space) => {
|
|
3531
|
+
const blobId = space.gi(['hash-object', '-w', '--stdin'], { input: 'one\n' }).trim();
|
|
3532
|
+
const entries = [];
|
|
3533
|
+
for (let entry = 0; entry < 40_000; entry += 1) {
|
|
3534
|
+
entries.push(Buffer.from(`100644 f${String(entry).padStart(6, '0')}\0`), Buffer.from(blobId, 'hex'));
|
|
3535
|
+
}
|
|
3536
|
+
const tree = space.gi(['hash-object', '-t', 'tree', '--literally', '-w', '--stdin'], { input: Buffer.concat(entries) }).trim();
|
|
3537
|
+
space.gi(['update-ref', 'HEAD', space.gi(['commit-tree', tree, '-p', 'HEAD', '-m', 'wide']).trim()]);
|
|
3538
|
+
}],
|
|
3539
|
+
])('refuses %s over the changed-bytes cap, although it changes no file', async (_, make) => {
|
|
3540
|
+
const host = createTestHost();
|
|
3541
|
+
const space = await spaceWithCode(host);
|
|
3542
|
+
make(space, host);
|
|
3543
|
+
const before = hostState(space.repo);
|
|
3544
|
+
const failure = await space.out({ maxChangedBytes: 512 * 1024 }).catch((error) => error);
|
|
3545
|
+
expect(failure).toMatchObject({ code: 'result_too_large', details: { step: 'measure the result' } });
|
|
3546
|
+
expect(failure.details.newBytes).toBeGreaterThan(512 * 1024);
|
|
3547
|
+
expect(anyChangeSince(before, space.repo)).toEqual([]);
|
|
3548
|
+
// The control: with room for it, the same result comes out, and it changes no file.
|
|
3549
|
+
const out = await space.out({ maxChangedBytes: 8 * 1024 * 1024 });
|
|
3550
|
+
expect(out.changedPaths).toBe(0);
|
|
3551
|
+
expect(space.g(['rev-parse', RESULT]).trim()).toBe(out.result);
|
|
3552
|
+
}, 60_000);
|
|
3553
|
+
|
|
3554
|
+
// A result small enough to arrive between two polls is held to the cap once the fetch is over. The
|
|
3555
|
+
// space's own sender ends normally here, which is what tells the two checks apart.
|
|
3556
|
+
it('refuses a transfer over the cap that arrived in one piece', async () => {
|
|
3557
|
+
const host = createTestHost();
|
|
3558
|
+
const space = await spaceWithCode(host);
|
|
3559
|
+
space.write('middling.bin', crypto.randomBytes(1536 * 1024));
|
|
3560
|
+
const before = hostState(space.repo);
|
|
3561
|
+
const failure = await space.out({ maxTransferBytes: 1024 * 1024 }).catch((error) => error);
|
|
3562
|
+
expect(failure).toMatchObject({ code: 'result_transfer_too_large', details: { step: 'fetch into the quarantine' } });
|
|
3563
|
+
expect(space.local.uploadPackExit(), 'the fetch was stopped in the middle, so this proves nothing about the cap after it').toBe('0');
|
|
3564
|
+
expect(anyChangeSince(before, space.repo)).toEqual([]);
|
|
3565
|
+
});
|
|
3566
|
+
|
|
3567
|
+
it('reports a repository the agent made inside its project, which travels as a gitlink and nothing else', async () => {
|
|
3568
|
+
const host = createTestHost();
|
|
3569
|
+
const space = await spaceWithCode(host);
|
|
3570
|
+
space.gi(['init', '--quiet', 'made-by-the-agent']);
|
|
3571
|
+
space.write('made-by-the-agent/inner.txt', 'inner\n');
|
|
3572
|
+
const nested = ['-C', path.join(space.inside, 'made-by-the-agent')];
|
|
3573
|
+
space.gi([...nested, 'add', 'inner.txt']);
|
|
3574
|
+
space.gi([...nested, '-c', 'user.name=Agent', '-c', 'user.email=agent@example.invalid', 'commit', '--quiet', '-m', 'inner']);
|
|
3575
|
+
const out = await space.out();
|
|
3576
|
+
expect(out.nestedRepositories).toEqual({ count: 1, paths: ['made-by-the-agent'] });
|
|
3577
|
+
expect(out.unmerged).toEqual({ count: 0, paths: [] });
|
|
3578
|
+
// What it is in the result: a gitlink, whose commit never travelled.
|
|
3579
|
+
expect(space.g(['ls-tree', out.result, 'made-by-the-agent']).split(' ')[0]).toBe('160000');
|
|
3580
|
+
await space.codeOut.applyAsChanges({ repository: space.repo, spaceId: SPACE_ID });
|
|
3581
|
+
expect(fs.readdirSync(path.join(space.repo, 'made-by-the-agent'))).toEqual([]);
|
|
3582
|
+
});
|
|
3583
|
+
|
|
3584
|
+
it('reports the paths the agent left in a conflicted merge, whose markers are in the result as content', async () => {
|
|
3585
|
+
const host = createTestHost();
|
|
3586
|
+
const space = await spaceWithCode(host);
|
|
3587
|
+
space.gi(['add', '--all']);
|
|
3588
|
+
space.gi(['commit', '--quiet', '-m', 'the agent starts']);
|
|
3589
|
+
space.gi(['checkout', '--quiet', '-b', 'side']);
|
|
3590
|
+
space.write('README.md', 'from the side branch\n');
|
|
3591
|
+
space.gi(['commit', '--quiet', '-am', 'side']);
|
|
3592
|
+
space.gi(['checkout', '--quiet', 'main']);
|
|
3593
|
+
space.write('README.md', 'from the main branch\n');
|
|
3594
|
+
space.gi(['commit', '--quiet', '-am', 'main']);
|
|
3595
|
+
expect(space.local.inside(['-C', space.inside, 'merge', 'side']).code).not.toBe(0);
|
|
3596
|
+
|
|
3597
|
+
const out = await space.out();
|
|
3598
|
+
expect(out.unmerged).toEqual({ count: 1, paths: ['README.md'] });
|
|
3599
|
+
expect(blob(space.g, out.result, 'README.md')).toContain('<<<<<<<');
|
|
3600
|
+
// The second parent of the merge is not in the result: it is one commit on one parent.
|
|
3601
|
+
expect(space.g(['rev-list', '--count', '--merges', `${space.came.base}..${out.result}`]).trim()).toBe('0');
|
|
3602
|
+
});
|
|
3603
|
+
|
|
3604
|
+
it.each([
|
|
3605
|
+
// One content in every file, so there are many paths and only a handful of new objects.
|
|
3606
|
+
['too many changed paths', (space) => { for (let file = 0; file < 150; file += 1) space.write(`many/${file}.txt`, 'the same\n'); }],
|
|
3607
|
+
['too many new objects in its history', (space) => {
|
|
3608
|
+
for (let file = 0; file < 150; file += 1) space.write(`many/${file}.txt`, `${file}\n`);
|
|
3609
|
+
space.gi(['add', 'many']);
|
|
3610
|
+
space.gi(['commit', '--quiet', '-m', 'many']);
|
|
3611
|
+
space.gi(['rm', '-r', '--quiet', 'many']);
|
|
3612
|
+
space.gi(['commit', '--quiet', '-m', 'none again']);
|
|
3613
|
+
}],
|
|
3614
|
+
])('refuses a result with %s', async (_, make) => {
|
|
3615
|
+
const host = createTestHost();
|
|
3616
|
+
const space = await spaceWithCode(host);
|
|
3617
|
+
make(space);
|
|
3618
|
+
const before = hostState(space.repo);
|
|
3619
|
+
await expect(space.out({ maxChangedEntries: 100 })).rejects.toMatchObject({ code: 'result_too_many_changes', details: { step: 'measure the result' } });
|
|
3620
|
+
expect(anyChangeSince(before, space.repo)).toEqual([]);
|
|
3621
|
+
await space.out({ maxChangedEntries: 1000 });
|
|
3622
|
+
});
|
|
3623
|
+
|
|
3624
|
+
// The folders are what a patch pays for, like the bytes it reads and writes, so the bring-out does
|
|
3625
|
+
// not refuse them: the branch holds such work, and only the apply as changes refuses it.
|
|
3626
|
+
it('brings out work through more folders than the cap, and only the apply as changes refuses it', async () => {
|
|
3627
|
+
const host = createTestHost();
|
|
3628
|
+
const space = await spaceWithCode(host);
|
|
3629
|
+
for (let index = 0; index < 30; index += 1) space.write(`x${index}/a/a/a/a/f.txt`, 'the same\n');
|
|
3630
|
+
const out = await space.out({ maxChangedEntries: 100 });
|
|
3631
|
+
expect(out.changedPaths).toBe(30);
|
|
3632
|
+
await expect(space.codeOut.applyAsChanges({ repository: space.repo, spaceId: SPACE_ID, maxChangedEntries: 100 }))
|
|
3633
|
+
.rejects.toMatchObject({ code: 'changes_too_large', message: expect.stringContaining('more than 100 folders') });
|
|
3634
|
+
expect(await space.codeOut.applyAsBranch({ repository: space.repo, spaceId: SPACE_ID, branch: 'deep' })).toEqual({ branch: 'deep', commit: out.result });
|
|
3635
|
+
});
|
|
3636
|
+
|
|
3637
|
+
// A second apply brings what is new since the first one, and nothing of it again.
|
|
3638
|
+
it('applies only what is new since the last apply, and leaves the user\'s own work beside it alone', async () => {
|
|
3639
|
+
const host = createTestHost();
|
|
3640
|
+
const space = await spaceWithCode(host);
|
|
3641
|
+
const { repo, g } = space;
|
|
3642
|
+
space.write('first round.txt', 'first\n');
|
|
3643
|
+
const first = await space.out();
|
|
3644
|
+
expect(await space.codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied', remembered: true });
|
|
3645
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(first.result);
|
|
3646
|
+
expect(workingTreeAs(host, repo, first.result)).toBe(g(['rev-parse', `${first.result}^{tree}`]).trim());
|
|
3647
|
+
|
|
3648
|
+
// The agent works on, and the user edits a file of their own in the meantime.
|
|
3649
|
+
space.write('second round.txt', 'second\n');
|
|
3650
|
+
space.write('first round.txt', 'first, changed by the agent\n');
|
|
3651
|
+
fs.writeFileSync(path.join(repo, 'only the user.txt'), 'the user wrote this\n');
|
|
3652
|
+
const second = await space.out();
|
|
3653
|
+
expect(await space.codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied', appliedPaths: 2, remembered: true });
|
|
3654
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(second.result);
|
|
3655
|
+
// Nothing was refused, so the route stays open.
|
|
3656
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
3657
|
+
expect(fs.readFileSync(path.join(repo, 'second round.txt'), 'utf8')).toBe('second\n');
|
|
3658
|
+
expect(fs.readFileSync(path.join(repo, 'first round.txt'), 'utf8')).toBe('first, changed by the agent\n');
|
|
3659
|
+
expect(fs.readFileSync(path.join(repo, 'only the user.txt'), 'utf8')).toBe('the user wrote this\n');
|
|
3660
|
+
// Everything but the user's own file is the second result.
|
|
3661
|
+
fs.rmSync(path.join(repo, 'only the user.txt'));
|
|
3662
|
+
expect(workingTreeAs(host, repo, second.result)).toBe(g(['rev-parse', `${second.result}^{tree}`]).trim());
|
|
3663
|
+
});
|
|
3664
|
+
|
|
3665
|
+
it('has nothing to apply when the same result is applied again', async () => {
|
|
3666
|
+
const host = createTestHost();
|
|
3667
|
+
const space = await spaceWithCode(host);
|
|
3668
|
+
space.write('once.txt', 'once\n');
|
|
3669
|
+
await space.out();
|
|
3670
|
+
expect(await space.codeOut.applyAsChanges({ repository: space.repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied' });
|
|
3671
|
+
const before = hostState(space.repo);
|
|
3672
|
+
expect(await space.codeOut.applyAsChanges({ repository: space.repo, spaceId: SPACE_ID })).toEqual({ status: 'nothing_to_apply' });
|
|
3673
|
+
// And again after the same result came out once more.
|
|
3674
|
+
await space.out();
|
|
3675
|
+
expect(await space.codeOut.applyAsChanges({ repository: space.repo, spaceId: SPACE_ID })).toEqual({ status: 'nothing_to_apply' });
|
|
3676
|
+
expect(changedSince(before, space.repo)).toEqual([]);
|
|
3677
|
+
});
|
|
3678
|
+
|
|
3679
|
+
it('touches nothing on a second apply that collides with the user\'s own edit, and remembers only what went through', async () => {
|
|
3680
|
+
const host = createTestHost();
|
|
3681
|
+
const space = await spaceWithCode(host);
|
|
3682
|
+
const { repo, g } = space;
|
|
3683
|
+
space.write('shared.txt', 'one\n');
|
|
3684
|
+
const first = await space.out();
|
|
3685
|
+
await space.codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID });
|
|
3686
|
+
// The user writes their own line where the agent's next change goes.
|
|
3687
|
+
fs.writeFileSync(path.join(repo, 'shared.txt'), 'one\nthe user\n');
|
|
3688
|
+
space.write('shared.txt', 'one\nthe agent\n');
|
|
3689
|
+
await space.out();
|
|
3690
|
+
const before = hostState(repo);
|
|
3691
|
+
const failure = await space.codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
3692
|
+
expect(failure).toMatchObject({ code: 'changes_do_not_apply' });
|
|
3693
|
+
expect(failure.message).toMatch(/since its work was last applied here/);
|
|
3694
|
+
expect(failure.message).toMatch(/applied as a branch/);
|
|
3695
|
+
expect(anyChangeSince(before, repo)).toEqual(CLOSURE);
|
|
3696
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(first.result);
|
|
3697
|
+
});
|
|
3698
|
+
|
|
3699
|
+
it('remembers nothing when the first apply is refused, and the branch closes and moves nothing', async () => {
|
|
3700
|
+
const host = createTestHost();
|
|
3701
|
+
const space = await spaceWithCode(host);
|
|
3702
|
+
const { repo, g } = space;
|
|
3703
|
+
space.write('tracked-to-edit.txt', 'one\ntwo staged\nthree unstaged\nfrom the space\n');
|
|
3704
|
+
const out = await space.out();
|
|
3705
|
+
// The user changed the same line first.
|
|
3706
|
+
fs.writeFileSync(path.join(repo, 'tracked-to-edit.txt'), 'one\ntwo staged\nthree changed by the user\n');
|
|
3707
|
+
await expect(space.codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID })).rejects.toMatchObject({ code: 'changes_do_not_apply' });
|
|
3708
|
+
expect(g(['for-each-ref', APPLIED])).toBe('');
|
|
3709
|
+
// The branch works after that, and moves neither of the two refs.
|
|
3710
|
+
await space.codeOut.applyAsBranch({ repository: repo, spaceId: SPACE_ID, branch: 'after-the-refusal' });
|
|
3711
|
+
expect(g(['rev-parse', 'refs/heads/after-the-refusal']).trim()).toBe(out.result);
|
|
3712
|
+
expect(g(['for-each-ref', APPLIED])).toBe('');
|
|
3713
|
+
expect(g(['rev-parse', CLOSED]).trim()).toBe(out.result);
|
|
3714
|
+
});
|
|
3715
|
+
|
|
3716
|
+
it('remembers an apply that went through, and a branch on either side of it changes nothing', async () => {
|
|
3717
|
+
const host = createTestHost();
|
|
3718
|
+
const space = await spaceWithCode(host);
|
|
3719
|
+
const { repo, g } = space;
|
|
3720
|
+
space.write('from the space.txt', 'from the space\n');
|
|
3721
|
+
const out = await space.out();
|
|
3722
|
+
await space.codeOut.applyAsBranch({ repository: repo, spaceId: SPACE_ID, branch: 'before-the-apply' });
|
|
3723
|
+
expect(g(['for-each-ref', APPLIED])).toBe('');
|
|
3724
|
+
expect(await space.codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied' });
|
|
3725
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(out.result);
|
|
3726
|
+
await space.codeOut.applyAsBranch({ repository: repo, spaceId: SPACE_ID, branch: 'after-the-apply' });
|
|
3727
|
+
expect(g(['rev-parse', APPLIED]).trim()).toBe(out.result);
|
|
3728
|
+
expect(g(['for-each-ref', CLOSED])).toBe('');
|
|
3729
|
+
});
|
|
3730
|
+
|
|
3731
|
+
// Once the two have gone apart, this space is applied as a branch, and every later attempt says so.
|
|
3732
|
+
it('closes the changes route after a refused round, and refuses at once from then on', async () => {
|
|
3733
|
+
const host = createTestHost();
|
|
3734
|
+
const space = await spaceWithCode(host);
|
|
3735
|
+
const { repo, g } = space;
|
|
3736
|
+
space.write('tracked-to-edit.txt', 'one\ntwo staged\nthree unstaged\nfrom the space\n');
|
|
3737
|
+
const first = await space.out();
|
|
3738
|
+
// The user changed the same line first.
|
|
3739
|
+
fs.writeFileSync(path.join(repo, 'tracked-to-edit.txt'), 'one\ntwo staged\nthree changed by the user\n');
|
|
3740
|
+
const refused = await space.codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
3741
|
+
expect(refused).toMatchObject({ code: 'changes_do_not_apply' });
|
|
3742
|
+
expect(refused.message).toMatch(/applied as a branch/);
|
|
3743
|
+
expect(refused.message).toMatch(/the rounds you already applied included/);
|
|
3744
|
+
expect(g(['rev-parse', CLOSED]).trim()).toBe(first.result);
|
|
3745
|
+
|
|
3746
|
+
// Every later attempt: the same answer, without a patch, whatever the project looks like now.
|
|
3747
|
+
const patchBuilds = [];
|
|
3748
|
+
const watched = {
|
|
3749
|
+
run: (directory, args, options) => { patchBuilds.push(args); return host.git.run(directory, args, options); },
|
|
3750
|
+
output: (directory, args, options) => { patchBuilds.push(args); return host.git.output(directory, args, options); },
|
|
3751
|
+
};
|
|
3752
|
+
const watching = createCodeOut({ git: watched, place: space.local.place, temporaryDirectory: host.root });
|
|
3753
|
+
// Even with the user's own line back, so a collision would no longer be the reason.
|
|
3754
|
+
fs.writeFileSync(path.join(repo, 'tracked-to-edit.txt'), 'one\ntwo staged\nthree unstaged\n');
|
|
3755
|
+
const before = hostState(repo);
|
|
3756
|
+
const closed = await watching.applyAsChanges({ repository: repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
3757
|
+
expect(closed).toMatchObject({ code: 'changes_route_closed' });
|
|
3758
|
+
expect(closed.message).toMatch(/no longer applied as uncommitted changes/);
|
|
3759
|
+
expect(closed.message).toMatch(/applied as a branch/);
|
|
3760
|
+
expect(patchBuilds.some((args) => args.some((argument) => String(argument).startsWith('--output=')))).toBe(false);
|
|
3761
|
+
expect(anyChangeSince(before, repo)).toEqual([]);
|
|
3762
|
+
|
|
3763
|
+
// The branch still works, and holds the same commit it would have held before.
|
|
3764
|
+
const branch = await space.codeOut.applyAsBranch({ repository: repo, spaceId: SPACE_ID, branch: 'from-the-space' });
|
|
3765
|
+
expect(branch.commit).toBe(first.result);
|
|
3766
|
+
expect(g(['rev-parse', 'refs/heads/from-the-space']).trim()).toBe(first.result);
|
|
3767
|
+
// And it closed nothing more and reopened nothing.
|
|
3768
|
+
expect(g(['rev-parse', CLOSED]).trim()).toBe(first.result);
|
|
3769
|
+
|
|
3770
|
+
// A new result of the same space changes nothing about it.
|
|
3771
|
+
space.write('more.txt', 'more\n');
|
|
3772
|
+
await space.out();
|
|
3773
|
+
await expect(space.codeOut.applyAsChanges({ repository: repo, spaceId: SPACE_ID })).rejects.toMatchObject({ code: 'changes_route_closed' });
|
|
3774
|
+
});
|
|
3775
|
+
|
|
3776
|
+
// A name Windows cannot hold, made in the space and brought out for real, with the rules of Windows
|
|
3777
|
+
// given to code out: refused, nothing touched, the route open, and after the agent renames the file
|
|
3778
|
+
// the next bring-out applies.
|
|
3779
|
+
it('refuses a name this computer cannot hold, and applies once the agent renamed it', async () => {
|
|
3780
|
+
const host = createTestHost();
|
|
3781
|
+
const space = await spaceWithCode(host);
|
|
3782
|
+
const codeOut = createCodeOut({ git: host.git, place: space.local.place, temporaryDirectory: host.root, platform: 'win32' });
|
|
3783
|
+
space.write('aux.txt', 'auxiliary\n');
|
|
3784
|
+
await codeOut.bringCodeOut(space.request());
|
|
3785
|
+
const before = hostState(space.repo);
|
|
3786
|
+
const failure = await codeOut.applyAsChanges({ repository: space.repo, spaceId: SPACE_ID }).catch((error) => error);
|
|
3787
|
+
expect(failure).toMatchObject({ code: 'name_not_allowed_here', details: { path: 'aux.txt', rule: 'device_name' } });
|
|
3788
|
+
expect(failure.message).toBe('The agent made aux.txt, which has a name this computer cannot hold, so nothing was changed. Have the agent rename it, then bring the work out again.');
|
|
3789
|
+
expect(anyChangeSince(before, space.repo)).toEqual([]);
|
|
3790
|
+
expect(space.g(['for-each-ref', CLOSED])).toBe('');
|
|
3791
|
+
|
|
3792
|
+
fs.renameSync(path.join(space.inside, 'aux.txt'), path.join(space.inside, 'auxiliary.txt'));
|
|
3793
|
+
await codeOut.bringCodeOut(space.request());
|
|
3794
|
+
expect(await codeOut.applyAsChanges({ repository: space.repo, spaceId: SPACE_ID })).toMatchObject({ status: 'applied' });
|
|
3795
|
+
expect(fs.readFileSync(path.join(space.repo, 'auxiliary.txt'), 'utf8')).toBe('auxiliary\n');
|
|
3796
|
+
expect(fs.existsSync(path.join(space.repo, 'aux.txt'))).toBe(false);
|
|
3797
|
+
});
|
|
3798
|
+
|
|
3799
|
+
it('refuses bad requests before it asks the space anything', async () => {
|
|
3800
|
+
const host = createTestHost();
|
|
3801
|
+
const { repo, g } = makeBait(host);
|
|
3802
|
+
const asked = [];
|
|
3803
|
+
const place = { execArgv: async (...args) => { asked.push(args); return []; }, exec: async (...args) => { asked.push(args); return { code: 1, stdout: '', stderr: '' }; } };
|
|
3804
|
+
const codeOut = host.codeOut(place);
|
|
3805
|
+
const request = { repository: repo, spaceId: SPACE_ID, spacePath: `/spaces/${SPACE_ID}/bait-repo-` };
|
|
3806
|
+
await expect(codeOut.bringCodeOut(request)).rejects.toMatchObject({ code: 'space_start_missing' });
|
|
3807
|
+
g(['update-ref', START, 'HEAD']);
|
|
3808
|
+
for (const [extra, code] of [
|
|
3809
|
+
[{ spacePath: `/spaces/${SPACE_ID}/../elsewhere` }, 'invalid_space_path'],
|
|
3810
|
+
[{ spacePath: `/spaces/${OTHER_SPACE_ID}/bait-repo-` }, 'invalid_space_path'],
|
|
3811
|
+
[{ spaceId: 'not an id' }, 'invalid_space_id'],
|
|
3812
|
+
[{ timeoutMs: 10 }, 'invalid_timeout'],
|
|
3813
|
+
[{ innerMarginSeconds: 0 }, 'invalid_inner_margin'],
|
|
3814
|
+
[{ maxTransferBytes: 0 }, 'invalid_limit'],
|
|
3815
|
+
[{ maxChangedBytes: 1.5 }, 'invalid_limit'],
|
|
3816
|
+
[{ maxChangedEntries: '100' }, 'invalid_limit'],
|
|
3817
|
+
[{ repository: path.join(host.root, 'not here') }, 'project_folder_missing'],
|
|
3818
|
+
]) {
|
|
3819
|
+
await expect(codeOut.bringCodeOut({ ...request, ...extra })).rejects.toMatchObject({ code });
|
|
3820
|
+
}
|
|
3821
|
+
await expect(codeOut.bringCodeOut(undefined)).rejects.toMatchObject({ code: 'invalid_space_id' });
|
|
3822
|
+
expect(asked).toEqual([]);
|
|
3823
|
+
});
|
|
3824
|
+
|
|
3825
|
+
it('turns a space that cannot be reached, and a snapshot that fails inside, into code_out_failed with the cause', async () => {
|
|
3826
|
+
const host = createTestHost();
|
|
3827
|
+
const space = await spaceWithCode(host);
|
|
3828
|
+
const gone = { execArgv: async () => { throw new SpaceError('space_not_found', 'no such space'); }, exec: async () => { throw new SpaceError('space_not_found', 'no such space'); } };
|
|
3829
|
+
await expect(host.codeOut(gone).bringCodeOut(space.request())).rejects.toMatchObject({ code: 'code_out_failed', details: { step: 'reach the space', cause: 'space_not_found' } });
|
|
3830
|
+
// The agent left the repository on a branch with no commit.
|
|
3831
|
+
space.gi(['checkout', '--quiet', '--orphan', 'empty']);
|
|
3832
|
+
const before = hostState(space.repo);
|
|
3833
|
+
await expect(space.out()).rejects.toMatchObject({ code: 'code_out_failed', details: { step: 'snapshot the space', cause: 'inside_command_failed' } });
|
|
3834
|
+
expect(anyChangeSince(before, space.repo)).toEqual([]);
|
|
3835
|
+
expect(leftFolders(host)).toEqual([]);
|
|
3836
|
+
});
|
|
3837
|
+
|
|
3838
|
+
it('keeps the error of the work when the temporary folder cannot be removed, and names the folder', async () => {
|
|
3839
|
+
const host = createTestHost();
|
|
3840
|
+
const space = await spaceWithCode(host);
|
|
3841
|
+
space.local.setBehaviour('refuse');
|
|
3842
|
+
const stuck = createCodeOut({ git: host.git, place: space.local.place, temporaryDirectory: host.root, removeDirectory: async () => { throw Object.assign(new Error('busy'), { code: 'EBUSY' }); } });
|
|
3843
|
+
const failure = await stuck.bringCodeOut(space.request()).catch((error) => error);
|
|
3844
|
+
expect(failure).toMatchObject({ code: 'code_out_failed', details: { step: 'fetch into the quarantine' } });
|
|
3845
|
+
expect(failure.details.temporaryDirectoryLeft).toEqual(expect.stringContaining('openchamber-code-out-'));
|
|
3846
|
+
});
|
|
3847
|
+
|
|
3848
|
+
it('works in a SHA-256 repository', async () => {
|
|
3849
|
+
const host = createTestHost();
|
|
3850
|
+
const space = await spaceWithCode(host, { objectFormat: 'sha256' });
|
|
3851
|
+
space.write('sha256.txt', 'sha256\n');
|
|
3852
|
+
const out = await space.out();
|
|
3853
|
+
expect(out.result).toMatch(/^[0-9a-f]{64}$/);
|
|
3854
|
+
await space.codeOut.applyAsChanges({ repository: space.repo, spaceId: SPACE_ID });
|
|
3855
|
+
expect(workingTreeAs(host, space.repo, out.result)).toBe(space.g(['rev-parse', `${out.result}^{tree}`]).trim());
|
|
3856
|
+
});
|
|
3857
|
+
|
|
3858
|
+
it('comes out into a project in a subfolder and into a linked worktree', async () => {
|
|
3859
|
+
const host = createTestHost();
|
|
3860
|
+
const space = await spaceWithCode(host, {
|
|
3861
|
+
repository: (bait) => {
|
|
3862
|
+
bait.g(['worktree', 'add', '--quiet', '-b', 'feature', path.join(host.root, 'linked')]);
|
|
3863
|
+
fs.mkdirSync(path.join(host.root, 'linked', 'app'));
|
|
3864
|
+
fs.writeFileSync(path.join(host.root, 'linked', 'app', 'index.js'), 'app\n');
|
|
3865
|
+
return path.join(host.root, 'linked', 'app');
|
|
3866
|
+
},
|
|
3867
|
+
});
|
|
3868
|
+
const linked = path.join(host.root, 'linked');
|
|
3869
|
+
space.write('app/index.js', 'app, changed in the space\n');
|
|
3870
|
+
space.write('top.txt', 'top\n');
|
|
3871
|
+
const out = await space.codeOut.bringCodeOut(space.request({ repository: path.join(linked, 'app') }));
|
|
3872
|
+
await space.codeOut.applyAsChanges({ repository: path.join(linked, 'app'), spaceId: SPACE_ID });
|
|
3873
|
+
expect(fs.readFileSync(path.join(linked, 'top.txt'), 'utf8')).toBe('top\n');
|
|
3874
|
+
expect(workingTreeAs(host, linked, out.result)).toBe(space.g(['rev-parse', `${out.result}^{tree}`]).trim());
|
|
3875
|
+
expect(host.sh(linked, ['symbolic-ref', 'HEAD']).trim()).toBe('refs/heads/feature');
|
|
3876
|
+
});
|
|
3877
|
+
|
|
3878
|
+
it('runs none of the user\'s hooks and no fsmonitor program on the host, bringing out or applying', async () => {
|
|
3879
|
+
const host = createTestHost();
|
|
3880
|
+
const hooks = path.join(host.root, 'global hooks');
|
|
3881
|
+
const markers = path.join(host.root, 'markers');
|
|
3882
|
+
fs.mkdirSync(hooks);
|
|
3883
|
+
fs.mkdirSync(markers);
|
|
3884
|
+
for (const name of HOOK_NAMES) {
|
|
3885
|
+
fs.writeFileSync(path.join(hooks, name), `#!/bin/sh\ntouch '${markers}/${name}'\ncat > /dev/null 2>&1 || true\n`, { mode: 0o755 });
|
|
3886
|
+
}
|
|
3887
|
+
// git runs an fsmonitor program through the shell, so its path has no space in it.
|
|
3888
|
+
const fsmonitor = path.join(host.root, 'fsmonitor.sh');
|
|
3889
|
+
fs.writeFileSync(fsmonitor, `#!/bin/sh\ntouch '${markers}/fsmonitor'\nexit 1\n`, { mode: 0o755 });
|
|
3890
|
+
const space = await spaceWithCode(host);
|
|
3891
|
+
host.addConfig(`[core]\n\thooksPath = ${forConfig(hooks)}\n\tfsmonitor = ${forConfig(fsmonitor)}`);
|
|
3892
|
+
// The control, in a copy: with this config a ref write, an index write and a status run them.
|
|
3893
|
+
const control = makeBait(host, { name: 'control' });
|
|
3894
|
+
control.g(['update-ref', 'refs/heads/control', 'HEAD']);
|
|
3895
|
+
control.g(['add', 'untracked plain.txt']);
|
|
3896
|
+
control.g(['status', '--porcelain']);
|
|
3897
|
+
expect(fs.readdirSync(markers)).toEqual(expect.arrayContaining(['reference-transaction', 'post-index-change', 'fsmonitor']));
|
|
3898
|
+
for (const marker of fs.readdirSync(markers)) fs.rmSync(path.join(markers, marker));
|
|
3899
|
+
|
|
3900
|
+
space.write('from the space.txt', 'x\n');
|
|
3901
|
+
await space.out();
|
|
3902
|
+
await space.codeOut.applyAsBranch({ repository: space.repo, spaceId: SPACE_ID, branch: 'from-the-space' });
|
|
3903
|
+
await space.codeOut.applyAsChanges({ repository: space.repo, spaceId: SPACE_ID });
|
|
3904
|
+
expect(fs.readdirSync(markers)).toEqual([]);
|
|
3905
|
+
});
|
|
3906
|
+
|
|
3907
|
+
// A fetch runs `maintenance --auto` and can write a commit graph. Neither may touch the user's `.git`.
|
|
3908
|
+
it('starts no automatic gc or maintenance and writes no commit graph, even for a user who asks for them', async () => {
|
|
3909
|
+
const host = createTestHost();
|
|
3910
|
+
const space = await spaceWithCode(host);
|
|
3911
|
+
const packs = (repo) => fs.readdirSync(path.join(repo, '.git', 'objects', 'pack')).filter((file) => file.endsWith('.pack')).sort();
|
|
3912
|
+
const twoPacks = ({ repo, g }) => {
|
|
3913
|
+
g(['repack', '-q', '-d']);
|
|
3914
|
+
g(['commit', '--quiet', '--allow-empty', '-m', 'a second pack']);
|
|
3915
|
+
g(['repack', '-q', '-d']);
|
|
3916
|
+
expect(packs(repo)).toHaveLength(2);
|
|
3917
|
+
};
|
|
3918
|
+
const control = makeBait(host, { name: 'control' });
|
|
3919
|
+
twoPacks(space);
|
|
3920
|
+
twoPacks(control);
|
|
3921
|
+
host.addConfig([
|
|
3922
|
+
'[gc]', '\tauto = 1', '\tautoDetach = false', '\tautoPackLimit = 1',
|
|
3923
|
+
'[maintenance]', '\tauto = true', '\tautoDetach = false', '\tstrategy = gc',
|
|
3924
|
+
'[maintenance "gc"]', '\tenabled = true',
|
|
3925
|
+
'[fetch]', '\twriteCommitGraph = true',
|
|
3926
|
+
].join('\n'));
|
|
3927
|
+
// The control, in the copy: an ordinary fetch consolidates the packs and writes a commit graph.
|
|
3928
|
+
// Plain git for the fetch, not `control.g`: the setup git opts out of maintenance, which is what
|
|
3929
|
+
// the control proves. The clone and the commit that give it something to fetch are setup.
|
|
3930
|
+
const source = path.join(host.root, 'fetch source');
|
|
3931
|
+
host.sh(host.root, ['clone', '--quiet', control.repo, source]);
|
|
3932
|
+
host.sh(source, ['commit', '--quiet', '--allow-empty', '-m', 'new']);
|
|
3933
|
+
const fetched = spawnSync('git', ['-C', control.repo, 'fetch', '--quiet', source, 'HEAD:refs/heads/fetched'], { env: host.environment, encoding: 'utf8', windowsHide: true });
|
|
3934
|
+
expect(fetched.status, fetched.stderr).toBe(0);
|
|
3935
|
+
expect(packs(control.repo).length).toBeLessThan(2);
|
|
3936
|
+
expect(fs.existsSync(path.join(control.repo, '.git', 'objects', 'info', 'commit-graph')) || fs.existsSync(path.join(control.repo, '.git', 'objects', 'info', 'commit-graphs'))).toBe(true);
|
|
3937
|
+
|
|
3938
|
+
const kept = packs(space.repo);
|
|
3939
|
+
space.write('from the space.txt', 'x\n');
|
|
3940
|
+
const before = hostState(space.repo);
|
|
3941
|
+
await space.out();
|
|
3942
|
+
expect(packs(space.repo)).toEqual(expect.arrayContaining(kept));
|
|
3943
|
+
expect(changedSince(before, space.repo)).toEqual([]);
|
|
3944
|
+
});
|
|
3945
|
+
});
|