diff --git a/docs/13_git_interface.md b/docs/13_git_interface.md index 5ed59b6..45bf2b2 100644 --- a/docs/13_git_interface.md +++ b/docs/13_git_interface.md @@ -6,10 +6,12 @@ > `packages/computer/src/backends/worker/`. Everything below > works today. -`workspace.git` is a major typed surface on `Workspace`, alongside `fs`, `runtime`, Assets, and Artifacts. It runs every operation against the -local SQLite-backed VFS through `isomorphic-git`, so a +`workspace.git` is a major typed surface on `Workspace`, alongside `fs`, `runtime`, Assets, and Artifacts. It is opt-in: pass `createGitClient()` from `@cloudflare/computer/git` as `WorkspaceOptions.git` to enable it. Git runs every operation against the local SQLite-backed VFS through `isomorphic-git`, so a filesystem-only workspace (no backend) can drive a full -clone/commit/diff cycle. +clone/commit/diff cycle. The git subpath bundles `isomorphic-git` +lazily and replaces its `pako` dependency with a small +`node:zlib` shim for Workers running with `nodejs_compat`; the +default `@cloudflare/computer` graph stays free of git. Two doors into the same implementation: @@ -111,8 +113,9 @@ diff against HEAD — through each entry point. ```ts import { Workspace } from "@cloudflare/computer"; +import { createGitClient } from "@cloudflare/computer/git"; -const ws = new Workspace({ storage: ctx.storage }); +const ws = new Workspace({ storage: ctx.storage, git: createGitClient() }); await ws.git.clone({ url: "https://github.com/example/repo.git" }); await ws.fs.writeFile("/README.md", "hello world\n"); const patch = await ws.git.diff(); @@ -174,8 +177,8 @@ committer in this order: by `git config user.email "..."`. Only the local `/.git/config` is consulted; there is no global `~/.gitconfig` fallback. -4. `defaultIdentity` from `createGitClient` / `new Workspace({ - defaultGitIdentity })`. +4. `defaultIdentity` from `createGitClient()` / `new Workspace({ + git: createGitClient(), defaultGitIdentity })`. If none of the four yields a name and email, `MissingIdentityError` fires. The CLI surfaces it as `git diff --git a/docs/README.md b/docs/README.md index 1e856a0..d9cfccc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,7 +46,7 @@ The package ships several entrypoints: | `@cloudflare/computer/backends/container` | `CloudflareContainerBackend` and `withWorkspaceContainer`. Pulls in the computerd / capnweb sync plumbing. | | `@cloudflare/computer/backends/worker` | `WorkerBackend` and the bundled just-bash command runtime. | | `@cloudflare/computer/backends/javascript` | `IsolateJavaScriptBackend`, configured libraries, durable relative imports, `node:fs/promises`, and trusted `ws:git` / `ws:artifacts`. | -| `@cloudflare/computer/git` | Isomorphic-git glue for working with checkouts inside the workspace. | +| `@cloudflare/computer/git` | Opt-in isomorphic-git glue for working with checkouts inside the workspace. Bundled lazily, with `pako` replaced by Workers `node:zlib`, and kept out of the default `@cloudflare/computer` graph. | | `@cloudflare/computer/artifacts` | `createArtifact`, a session-scoped facade over the Cloudflare Artifacts Workers binding, plus its argv CLI. | | `@cloudflare/computer/tools` | AI SDK tools for agents: read, write, edit, ls, optional exec, and optional publish. | diff --git a/package-lock.json b/package-lock.json index acd4bcd..4137a7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12197,7 +12197,6 @@ "peerDependencies": { "@platformatic/vfs": "*", "ai": "^6.0.196 || ^7.0.0", - "isomorphic-git": "^1.27.0", "zod": "^4.4.3" }, "peerDependenciesMeta": { @@ -12207,9 +12206,6 @@ "ai": { "optional": true }, - "isomorphic-git": { - "optional": true - }, "zod": { "optional": true } diff --git a/packages/computer/README.md b/packages/computer/README.md index 5304600..907c239 100644 --- a/packages/computer/README.md +++ b/packages/computer/README.md @@ -56,12 +56,17 @@ uniform; the counts are just always zero. surface: `exec`, `getExec`, `killExec`, and `disposeExec`. The selected backend defines the source language. JavaScript results may include a structured `value`; command backends return stdout/stderr and an exit code. -- `workspace.git` — a typed git client backed by - `isomorphic-git` against the local SQLite VFS. Surfaces both a - TypeScript API (`workspace.git.clone({ url })`) and an - argv-driven entry point (`workspace.git.cli({ argv })`). The - worker backend's shell exposes the same dispatcher through a - built-in `git` custom command. See +- `workspace.git` — an opt-in typed git client backed by + `isomorphic-git` against the local SQLite VFS. Pass + `createGitClient()` from `@cloudflare/computer/git` as + `WorkspaceOptions.git` to enable both the TypeScript API + (`workspace.git.clone({ url })`) and the argv-driven entry point + (`workspace.git.cli({ argv })`). The git subpath bundles + `isomorphic-git` lazily and replaces its `pako` dependency with + the Workers `node:zlib` implementation, so the default package + graph stays free of git. The worker backend's shell exposes the + same dispatcher through a built-in `git` custom command when git + is configured. See [`docs/13_git_interface.md`](../../docs/13_git_interface.md). - `createAssets` (from `@cloudflare/computer/assets`) — `share` a workspace file to an R2 bucket and get back a presigned URL. @@ -158,8 +163,11 @@ and `workspace.runtime` as the primary surfaces. Git, also without a backend: ```ts +import { createGitClient } from "@cloudflare/computer/git"; + const ws = new Workspace({ storage: ctx.storage, + git: createGitClient(), defaultGitIdentity: { name: "Agent", email: "agent@example.test" }, }); await ws.git.clone({ url: "https://github.com/example/repo.git" }); diff --git a/packages/computer/package.json b/packages/computer/package.json index 33b5ba7..20f9a33 100644 --- a/packages/computer/package.json +++ b/packages/computer/package.json @@ -79,7 +79,6 @@ "peerDependencies": { "@platformatic/vfs": "*", "ai": "^6.0.196 || ^7.0.0", - "isomorphic-git": "^1.27.0", "zod": "^4.4.3" }, "peerDependenciesMeta": { @@ -89,9 +88,6 @@ "ai": { "optional": true }, - "isomorphic-git": { - "optional": true - }, "zod": { "optional": true } diff --git a/packages/computer/rolldown.config.ts b/packages/computer/rolldown.config.ts index 10b2160..044c57e 100644 --- a/packages/computer/rolldown.config.ts +++ b/packages/computer/rolldown.config.ts @@ -8,8 +8,11 @@ // - capnweb — a regular npm dep on this package. // - @platformatic/vfs — optional userland import used by // examples but not the workspace core. -// `node:*` builtins are externalised automatically because -// `platform: "node"` is the default for `esm` output here. +// - node:* — provided by nodejs_compat in workerd. +// +// The git entrypoint bundles isomorphic-git, but aliases pako to a +// tiny node:zlib-backed compatibility layer so Workers don't carry +// pako's JavaScript zlib implementation. import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -39,11 +42,8 @@ export default defineConfig({ "@platformatic/vfs", "ai", "zod", - "isomorphic-git", - /^isomorphic-git\//, "just-bash", - "node:crypto", - "node:events", + /^node:/, ], resolve: { alias: { @@ -51,6 +51,7 @@ export default defineConfig({ "@cloudflare/dofs/testing": resolve(here, "../dofs/src/testing.ts"), "@cloudflare/computer-rpc": resolve(here, "../rpc/src/index.ts"), "@cloudflare/computer-rpc/driver": resolve(here, "../rpc/src/sync-driver.ts"), + pako: resolve(here, "src/git/pako-zlib-shim.ts"), }, }, // ESM only. Nothing in-tree loads CJS — the example Worker, computerd, diff --git a/packages/computer/src/backends/worker/entrypoint.test.ts b/packages/computer/src/backends/worker/entrypoint.test.ts index 0a98856..5dc55e1 100644 --- a/packages/computer/src/backends/worker/entrypoint.test.ts +++ b/packages/computer/src/backends/worker/entrypoint.test.ts @@ -19,6 +19,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { FakeArtifactsBinding } from "../../../tests/utilities/fake-artifacts-binding.js"; import type { ArtifactsCLIInput, ArtifactsCLIResult } from "../../artifacts/index.js"; import type { BackendHandle, WorkspaceBackend } from "../../backend.js"; +import { createGitClient } from "../../git/index.js"; import type { WorkspaceStub } from "../../stub.js"; import { Workspace } from "../../workspace.js"; import { ShellWorker } from "./entrypoint.js"; @@ -268,6 +269,7 @@ describe("ShellWorker", () => { workspace = new Workspace({ storage: new SQLiteTestStorage() as never, backends: [noopBackend()], + git: createGitClient(), }); await workspace.ready(); // /workspace is the ShellWorker's default cwd; create it diff --git a/packages/computer/src/client.ts b/packages/computer/src/client.ts index ea54f8e..61e5d10 100644 --- a/packages/computer/src/client.ts +++ b/packages/computer/src/client.ts @@ -372,7 +372,9 @@ export async function getWorkspace(handle: WorkspaceHandle): Promise { describe("runGitCli — end-to-end against an in-process Workspace", () => { it("diff prints the working-tree delta against HEAD", async () => { - const ws = new Workspace({ storage: new SQLiteTestStorage() }); + const ws = new Workspace({ git: createGitClient(), storage: new SQLiteTestStorage() }); await ws.ready(); // Seed a repo with one committed file, then mutate the @@ -1987,6 +1987,7 @@ describe("runGitCli — end-to-end against an in-process Workspace", () => { // any subcommand drifts from the typed surface, the chain // breaks here rather than in a downstream consumer. const ws = new Workspace({ + git: createGitClient(), storage: new SQLiteTestStorage(), defaultGitIdentity: { name: "Test", email: "test@example.test" }, }); @@ -2030,6 +2031,7 @@ describe("runGitCli — end-to-end against an in-process Workspace", () => { it("log / show / rev-parse / ls-files round-trip", async () => { const ws = new Workspace({ + git: createGitClient(), storage: new SQLiteTestStorage(), defaultGitIdentity: { name: "Test", email: "test@example.test" }, }); @@ -2065,6 +2067,7 @@ describe("runGitCli — end-to-end against an in-process Workspace", () => { it("hash-object / cat-file / update-ref / config round-trip", async () => { const ws = new Workspace({ + git: createGitClient(), storage: new SQLiteTestStorage(), defaultGitIdentity: { name: "Test", email: "test@example.test" }, }); @@ -2104,7 +2107,7 @@ describe("runGitCli — end-to-end against an in-process Workspace", () => { }); it("remote add / list / remove round-trip through the config file", async () => { - const ws = new Workspace({ storage: new SQLiteTestStorage() }); + const ws = new Workspace({ git: createGitClient(), storage: new SQLiteTestStorage() }); await ws.ready(); const cli = (argv: string[]) => ws.git.cli({ argv, cwd: "/" }); await cli(["init"]); @@ -2128,6 +2131,7 @@ describe("runGitCli — end-to-end against an in-process Workspace", () => { it("branch / checkout / tag round-trip moves HEAD and creates refs", async () => { const ws = new Workspace({ + git: createGitClient(), storage: new SQLiteTestStorage(), defaultGitIdentity: { name: "Test", email: "test@example.test" }, }); @@ -2162,6 +2166,7 @@ describe("runGitCli — end-to-end against an in-process Workspace", () => { it("switch restores tracked file content from the target branch", async () => { const ws = new Workspace({ + git: createGitClient(), storage: new SQLiteTestStorage(), defaultGitIdentity: { name: "Test", email: "test@example.test" }, }); @@ -2184,6 +2189,7 @@ describe("runGitCli — end-to-end against an in-process Workspace", () => { it("reset HEAD unstages all staged changes", async () => { const ws = new Workspace({ + git: createGitClient(), storage: new SQLiteTestStorage(), defaultGitIdentity: { name: "Test", email: "test@example.test" }, }); @@ -2205,7 +2211,7 @@ describe("runGitCli — end-to-end against an in-process Workspace", () => { }); it("commit without identity surfaces as exit 128", async () => { - const ws = new Workspace({ storage: new SQLiteTestStorage() }); + const ws = new Workspace({ git: createGitClient(), storage: new SQLiteTestStorage() }); await ws.ready(); await ws.git.cli({ argv: ["init"], cwd: "/" }); await ws.fs.writeFile("/a.txt", "x\n"); @@ -2221,7 +2227,7 @@ describe("runGitCli — end-to-end against an in-process Workspace", () => { // would drive it: configure identity, stage with -A, commit // with -am, inspect with the new flags, branch with switch // -c, then reset / stash / clean. - const ws = new Workspace({ storage: new SQLiteTestStorage() }); + const ws = new Workspace({ git: createGitClient(), storage: new SQLiteTestStorage() }); await ws.ready(); const cli = (argv: string[]) => ws.git.cli({ argv, cwd: "/" }); @@ -2299,18 +2305,17 @@ describe("runGitCli — end-to-end against an in-process Workspace", () => { // Force the clone path to fail by pointing at an invalid host; // we want to pin that the dispatcher's catch arm produces a // CLI-shaped result and doesn't propagate the rejection. - const ws = new Workspace({ storage: new SQLiteTestStorage() }); + const ws = new Workspace({ git: createGitClient(), storage: new SQLiteTestStorage() }); await ws.ready(); // Swap the git client out for one whose clone rejects, so we // don't depend on network reachability inside the test runner. const failing: GitClient = createGitClient({ - ws, adapter: async () => ({ promises: { readFile: vi.fn(async () => new Uint8Array()), }, }), - }); + })({ ws }); // Replace `clone` with a deterministic failure — the real // path is exercised by `clone.test.ts`. (failing as { clone: GitClient["clone"] }).clone = async () => { diff --git a/packages/computer/src/git/index.test.ts b/packages/computer/src/git/index.test.ts index db39a11..7e08446 100644 --- a/packages/computer/src/git/index.test.ts +++ b/packages/computer/src/git/index.test.ts @@ -30,7 +30,7 @@ describe("createGitClient", () => { const fs = stubFs(); const adapter = vi.fn(async () => fs); - const client = createGitClient({ ws: { provider }, adapter }); + const client = createGitClient({ adapter })({ ws: { provider } }); // No work happens at construction time. expect(provider).not.toHaveBeenCalled(); diff --git a/packages/computer/src/git/index.ts b/packages/computer/src/git/index.ts index 478324b..a69300a 100644 --- a/packages/computer/src/git/index.ts +++ b/packages/computer/src/git/index.ts @@ -1,15 +1,16 @@ // Public surface of @cloudflare/computer/git. // -// `createGitClient({ ws })` is the one entry point. It binds a -// workspace handle once and returns a `GitClient` whose methods -// don't repeat the workspace argument. Today the typed surface is +// `createGitClient()` is the one entry point. It returns a +// WorkspaceOptions.git factory; the factory binds a workspace +// handle once and returns a `GitClient` whose methods don't repeat +// the workspace argument. Today the typed surface is // `clone` and `diff`; `cli` is the argv-driven door into the same // implementations, used by the worker-backend's `git` custom // command in the shell isolate. // -// Internally each method lazy-loads its optional peer deps -// (`isomorphic-git`, the http transport, and for `diff` the -// `diff` package) and delegates to `cloneWith` / `diffWith`. The +// Internally each method lazy-loads its heavy deps +// (`isomorphic-git`, the http transport, and `diff` for patches) +// and delegates to `cloneWith` / `diffWith`. The // loaders are memoised on the client so the dynamic imports fire // once across the lifetime of the client — without that, the CLI // would multiply the cost (re-importing isomorphic-git on every @@ -315,7 +316,7 @@ export interface GitClient { cli(input: GitCliInput): Promise; } -export interface CreateGitClientOptions { +export interface WorkspaceGitClientOptions { /** Workspace whose provider backs the git operations. */ ws: WorkspaceLike; /** @@ -325,6 +326,9 @@ export interface CreateGitClientOptions { * `GIT_COMMITTER_*` env vars are absent. */ defaultIdentity?: GitIdentity; +} + +export interface CreateGitClientOptions { /** * Test seam for substituting the @platformatic/vfs adapter. * Production callers do not pass this. @@ -332,6 +336,8 @@ export interface CreateGitClientOptions { adapter?: (provider: SQLiteWorkspaceProvider) => Promise; } +export type GitClientFactory = (options: WorkspaceGitClientOptions) => GitClient; + /** * Build a git client bound to a workspace. * @@ -353,344 +359,347 @@ export interface CreateGitClientOptions { * or CLI) reuses the resolved modules. */ export function createGitClient({ - ws, - defaultIdentity, adapter = workspaceIsomorphicGitClient, -}: CreateGitClientOptions): GitClient { - let fsPromise: Promise | undefined; - const fs = () => { - if (!fsPromise) fsPromise = adapter(ws.provider()); - return fsPromise; - }; - const cache: Record = {}; +}: CreateGitClientOptions = {}): GitClientFactory { + return function createWorkspaceGitClient({ + ws, + defaultIdentity, + }: WorkspaceGitClientOptions): GitClient { + let fsPromise: Promise | undefined; + const fs = () => { + if (!fsPromise) fsPromise = adapter(ws.provider()); + return fsPromise; + }; + const cache: Record = {}; - // Memoised module loaders. Each holds the promise returned by - // the dynamic import so concurrent first-use callers share one - // import pass, and subsequent callers reuse the resolved module - // synchronously through the cached promise. - let gitPromise: Promise | undefined; - let httpPromise: Promise | undefined; - let createPatchPromise: Promise | undefined; - const loadGit = (): Promise => { - if (!gitPromise) gitPromise = loadIsomorphicGit(); - return gitPromise as Promise; - }; - const loadHttp = (): Promise => { - if (!httpPromise) httpPromise = loadDefaultHTTP(); - return httpPromise; - }; - const loadDiffPatch = (): Promise => { - if (!createPatchPromise) createPatchPromise = loadCreatePatch(); - return createPatchPromise; - }; + // Memoised module loaders. Each holds the promise returned by + // the dynamic import so concurrent first-use callers share one + // import pass, and subsequent callers reuse the resolved module + // synchronously through the cached promise. + let gitPromise: Promise | undefined; + let httpPromise: Promise | undefined; + let createPatchPromise: Promise | undefined; + const loadGit = (): Promise => { + if (!gitPromise) gitPromise = loadIsomorphicGit(); + return gitPromise as Promise; + }; + const loadHttp = (): Promise => { + if (!httpPromise) httpPromise = loadDefaultHTTP(); + return httpPromise; + }; + const loadDiffPatch = (): Promise => { + if (!createPatchPromise) createPatchPromise = loadCreatePatch(); + return createPatchPromise; + }; - const client: GitClient = { - async clone(options) { - await cloneWith({ - ...options, - fs: await fs(), - git: await loadGit(), - http: await loadHttp(), - cache, - }); - }, - async diff(options = {}) { - const f = await fs(); - return diffWith({ - ...options, - fs: f, - git: await loadGit(), - createPatch: await loadDiffPatch(), - readFile: readFileFrom(f), - cache, - }); - }, - async diffSummary(options = {}) { - const f = await fs(); - return diffSummaryWith({ - ...options, - fs: f, - git: await loadGit(), - createPatch: await loadDiffPatch(), - readFile: readFileFrom(f), - cache, - }); - }, - async init(options = {}) { - await initWith({ - ...options, - fs: await fs(), - git: await loadGit(), - }); - }, - async status(options = {}) { - return statusWith({ - ...options, - fs: await fs(), - git: await loadGit(), - cache, - }); - }, - async add(options) { - await addWith({ - ...options, - fs: await fs(), - git: await loadGit(), - cache, - }); - }, - async rm(options) { - await rmWith({ - ...options, - fs: await fs(), - git: await loadGit(), - cache, - }); - }, - async commit(options) { - return commitWith({ - ...options, - fs: await fs(), - git: await loadGit(), - cache, - defaultIdentity, - }); - }, - async log(options = {}) { - return logWith({ - ...options, - fs: await fs(), - git: await loadGit(), - cache, - }); - }, - async show(options) { - return showWith({ - ...options, - fs: await fs(), - git: await loadGit(), - cache, - }); - }, - async revParse(options) { - return revParseWith({ - ...options, - fs: await fs(), - git: await loadGit(), - }); - }, - async repoRoot(options = {}) { - return repoRootWith({ ...options, fs: await fs() }); - }, - async currentBranch(options = {}) { - return currentBranchWith({ - ...options, - fs: await fs(), - git: await loadGit(), - }); - }, - async lsFiles(options = {}) { - return lsFilesWith({ - ...options, - fs: await fs(), - git: await loadGit(), - cache, - }); - }, - async lsTree(options) { - return lsTreeWith({ - ...options, - fs: await fs(), - git: await loadGit(), - cache, - }); - }, - async branch(options) { - return branchWith({ - ...options, - fs: await fs(), - git: await loadGit(), - }); - }, - async branchDelete(options) { - return branchDeleteWith({ - ...options, - fs: await fs(), - git: await loadGit(), - }); - }, - async branchList(options = {}) { - return branchListWith({ - ...options, - fs: await fs(), - git: await loadGit(), - }); - }, - async tag(options) { - return tagWith({ - ...options, - fs: await fs(), - git: await loadGit(), - }); - }, - async tagDelete(options) { - return tagDeleteWith({ - ...options, - fs: await fs(), - git: await loadGit(), - }); - }, - async tagList(options = {}) { - return tagListWith({ - ...options, - fs: await fs(), - git: await loadGit(), - }); - }, - async checkout(options) { - return checkoutWith({ - ...options, - fs: await fs(), - git: await loadGit(), - cache, - }); - }, - async fetch(options = {}) { - return fetchWith({ - ...options, - fs: await fs(), - git: await loadGit(), - http: await loadHttp(), - cache, - }); - }, - async push(options = {}) { - return pushWith({ - ...options, - fs: await fs(), - git: await loadGit(), - http: await loadHttp(), - cache, - }); - }, - async pull(options = {}) { - return pullWith({ - ...options, - fs: await fs(), - git: await loadGit(), - http: await loadHttp(), - cache, - defaultIdentity, - }); - }, - async merge(options) { - return mergeWith({ - ...options, - fs: await fs(), - git: await loadGit(), - cache, - defaultIdentity, - }); - }, - async remoteAdd(options) { - return remoteAddWith({ - ...options, - fs: await fs(), - git: await loadGit(), - }); - }, - async remoteRemove(options) { - return remoteRemoveWith({ - ...options, - fs: await fs(), - git: await loadGit(), - }); - }, - async remoteList(options = {}) { - return remoteListWith({ - ...options, - fs: await fs(), - git: await loadGit(), - }); - }, - async hashObject(options) { - return hashObjectWith({ - ...options, - fs: await fs(), - git: await loadGit(), - }); - }, - async catFile(options) { - return catFileWith({ - ...options, - fs: await fs(), - git: await loadGit(), - cache, - }); - }, - async updateRef(options) { - return updateRefWith({ - ...options, - fs: await fs(), - git: await loadGit(), - }); - }, - async configGet(options) { - return configGetWith({ - ...options, - fs: await fs(), - git: await loadGit(), - }); - }, - async configSet(options) { - return configSetWith({ - ...options, - fs: await fs(), - git: await loadGit(), - }); - }, - async stashPush(options = {}) { - return stashPushWith({ - ...options, - fs: await fs(), - git: await loadGit(), - }); - }, - async stashList(options = {}) { - return stashListWith({ - ...options, - fs: await fs(), - git: await loadGit(), - }); - }, - async stashPop(options = {}) { - return stashPopWith({ - ...options, - fs: await fs(), - git: await loadGit(), - }); - }, - async reset(options = {}) { - return resetWith({ - ...options, - fs: await fs(), - git: await loadGit(), - cache, - }); - }, - async clean(options = {}) { - return cleanWith({ - ...options, - fs: await fs(), - git: await loadGit(), - cache, - }); - }, - async cli(input) { - return runGitCli(client, input, { defaultIdentity }); - }, + const client: GitClient = { + async clone(options) { + await cloneWith({ + ...options, + fs: await fs(), + git: await loadGit(), + http: await loadHttp(), + cache, + }); + }, + async diff(options = {}) { + const f = await fs(); + return diffWith({ + ...options, + fs: f, + git: await loadGit(), + createPatch: await loadDiffPatch(), + readFile: readFileFrom(f), + cache, + }); + }, + async diffSummary(options = {}) { + const f = await fs(); + return diffSummaryWith({ + ...options, + fs: f, + git: await loadGit(), + createPatch: await loadDiffPatch(), + readFile: readFileFrom(f), + cache, + }); + }, + async init(options = {}) { + await initWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async status(options = {}) { + return statusWith({ + ...options, + fs: await fs(), + git: await loadGit(), + cache, + }); + }, + async add(options) { + await addWith({ + ...options, + fs: await fs(), + git: await loadGit(), + cache, + }); + }, + async rm(options) { + await rmWith({ + ...options, + fs: await fs(), + git: await loadGit(), + cache, + }); + }, + async commit(options) { + return commitWith({ + ...options, + fs: await fs(), + git: await loadGit(), + cache, + defaultIdentity, + }); + }, + async log(options = {}) { + return logWith({ + ...options, + fs: await fs(), + git: await loadGit(), + cache, + }); + }, + async show(options) { + return showWith({ + ...options, + fs: await fs(), + git: await loadGit(), + cache, + }); + }, + async revParse(options) { + return revParseWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async repoRoot(options = {}) { + return repoRootWith({ ...options, fs: await fs() }); + }, + async currentBranch(options = {}) { + return currentBranchWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async lsFiles(options = {}) { + return lsFilesWith({ + ...options, + fs: await fs(), + git: await loadGit(), + cache, + }); + }, + async lsTree(options) { + return lsTreeWith({ + ...options, + fs: await fs(), + git: await loadGit(), + cache, + }); + }, + async branch(options) { + return branchWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async branchDelete(options) { + return branchDeleteWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async branchList(options = {}) { + return branchListWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async tag(options) { + return tagWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async tagDelete(options) { + return tagDeleteWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async tagList(options = {}) { + return tagListWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async checkout(options) { + return checkoutWith({ + ...options, + fs: await fs(), + git: await loadGit(), + cache, + }); + }, + async fetch(options = {}) { + return fetchWith({ + ...options, + fs: await fs(), + git: await loadGit(), + http: await loadHttp(), + cache, + }); + }, + async push(options = {}) { + return pushWith({ + ...options, + fs: await fs(), + git: await loadGit(), + http: await loadHttp(), + cache, + }); + }, + async pull(options = {}) { + return pullWith({ + ...options, + fs: await fs(), + git: await loadGit(), + http: await loadHttp(), + cache, + defaultIdentity, + }); + }, + async merge(options) { + return mergeWith({ + ...options, + fs: await fs(), + git: await loadGit(), + cache, + defaultIdentity, + }); + }, + async remoteAdd(options) { + return remoteAddWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async remoteRemove(options) { + return remoteRemoveWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async remoteList(options = {}) { + return remoteListWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async hashObject(options) { + return hashObjectWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async catFile(options) { + return catFileWith({ + ...options, + fs: await fs(), + git: await loadGit(), + cache, + }); + }, + async updateRef(options) { + return updateRefWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async configGet(options) { + return configGetWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async configSet(options) { + return configSetWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async stashPush(options = {}) { + return stashPushWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async stashList(options = {}) { + return stashListWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async stashPop(options = {}) { + return stashPopWith({ + ...options, + fs: await fs(), + git: await loadGit(), + }); + }, + async reset(options = {}) { + return resetWith({ + ...options, + fs: await fs(), + git: await loadGit(), + cache, + }); + }, + async clean(options = {}) { + return cleanWith({ + ...options, + fs: await fs(), + git: await loadGit(), + cache, + }); + }, + async cli(input) { + return runGitCli(client, input, { defaultIdentity }); + }, + }; + return client; }; - return client; } // isomorphic-git ships both named exports and a default export @@ -701,8 +710,7 @@ async function loadIsomorphicGit(): Promise { return (mod.default ?? mod) as unknown as T; } catch (cause) { throw new Error( - "@cloudflare/computer/git requires isomorphic-git as an optional peer dependency. " + - "Install isomorphic-git.", + "Failed to load @cloudflare/computer/git's bundled isomorphic-git implementation.", { cause }, ); } @@ -713,7 +721,7 @@ async function loadDefaultHTTP(): Promise { const mod = await import("isomorphic-git/http/web"); return mod.default; } catch (cause) { - throw new Error("Failed to load isomorphic-git/http/web. Install isomorphic-git.", { cause }); + throw new Error("Failed to load @cloudflare/computer/git's bundled HTTP transport.", { cause }); } } @@ -722,11 +730,9 @@ async function loadCreatePatch(): Promise { const mod = await import("diff"); return mod.createPatch; } catch (cause) { - throw new Error( - "@cloudflare/computer/git requires `diff` as an optional peer dependency. " + - "Install `diff`.", - { cause }, - ); + throw new Error("Failed to load @cloudflare/computer/git's bundled diff implementation.", { + cause, + }); } } diff --git a/packages/computer/src/git/pako-zlib-shim.test.ts b/packages/computer/src/git/pako-zlib-shim.test.ts new file mode 100644 index 0000000..b3a6046 --- /dev/null +++ b/packages/computer/src/git/pako-zlib-shim.test.ts @@ -0,0 +1,62 @@ +import * as zlib from "node:zlib"; + +import { describe, expect, it } from "vitest"; + +import pako, { deflate, Inflate, inflate } from "./pako-zlib-shim.js"; + +function bytes(text: string): Uint8Array { + return new TextEncoder().encode(text); +} + +function asBytes(value: Uint8Array | string): Uint8Array { + expect(value).toBeInstanceOf(Uint8Array); + return value as Uint8Array; +} + +describe("pako zlib shim", () => { + it("deflates and inflates pako-style Uint8Array values through node:zlib", () => { + const input = bytes("hello from git objects\n"); + + const compressed = asBytes(deflate(input)); + expect(zlib.inflateSync(compressed).toString("utf8")).toBe("hello from git objects\n"); + + const roundTrip = asBytes(inflate(compressed)); + expect(new TextDecoder().decode(roundTrip)).toBe("hello from git objects\n"); + }); + + it("exposes a default pako-compatible object", () => { + expect(pako.deflate).toBe(deflate); + expect(pako.inflate).toBe(inflate); + expect(pako.Inflate).toBe(Inflate); + expect(typeof pako.Z_FINISH).toBe("number"); + }); + + it("tracks unused compressed bytes for isomorphic-git's pack parser", () => { + const first = asBytes(deflate(bytes("first object"))); + const extra = bytes("NEXT"); + const streamBytes = new Uint8Array(first.length + extra.length); + streamBytes.set(first); + streamBytes.set(extra, first.length); + + const inflator = new Inflate(); + const split = 2; + + expect(inflator.push(streamBytes.subarray(0, split), false)).toBe(true); + expect(inflator.result).toBeUndefined(); + expect(inflator.err).toBe(0); + + expect(inflator.push(streamBytes.subarray(split), false)).toBe(true); + expect(new TextDecoder().decode(inflator.result)).toBe("first object"); + expect(inflator.strm.avail_in).toBe(extra.length); + expect(inflator.err).toBe(0); + }); + + it("reports corrupt input like pako.Inflate instead of throwing", () => { + const inflator = new Inflate(); + + expect(inflator.push(bytes("not deflate data"), true)).toBe(false); + expect(inflator.result).toBeUndefined(); + expect(inflator.err).not.toBe(0); + expect(inflator.msg).toContain("incorrect"); + }); +}); diff --git a/packages/computer/src/git/pako-zlib-shim.ts b/packages/computer/src/git/pako-zlib-shim.ts new file mode 100644 index 0000000..151e3eb --- /dev/null +++ b/packages/computer/src/git/pako-zlib-shim.ts @@ -0,0 +1,221 @@ +// pako compatibility layer backed by the Workers / Node `node:zlib` +// implementation. +// +// isomorphic-git imports pako for three things: synchronous +// `deflate`, synchronous `inflate`, and an `Inflate` instance whose +// `push()` call exposes `result`, `err`, `msg`, and +// `strm.avail_in`. Workers provide native zlib through nodejs_compat, +// so the package build aliases `pako` here instead of bundling pako's +// JavaScript zlib port. + +import * as zlib from "node:zlib"; + +export type Data = string | ArrayBuffer | ArrayBufferView; + +export interface PakoOptions extends zlib.ZlibOptions { + raw?: boolean; + gzip?: boolean; + to?: "string"; +} + +export interface PakoStream { + avail_in: number; +} + +type ZlibInfoResult = { + buffer: Uint8Array; + engine?: { + bytesWritten?: number; + }; +}; + +type ZlibError = Error & { + code?: string; + errno?: number; +}; + +const UTF8_ENCODER = /* @__PURE__ */ new TextEncoder(); +const UTF8_DECODER = /* @__PURE__ */ new TextDecoder(); + +function toBytes(data: Data): Uint8Array { + if (typeof data === "string") return UTF8_ENCODER.encode(data); + if (data instanceof ArrayBuffer) return new Uint8Array(data); + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); +} + +function concat(chunks: Uint8Array[]): Uint8Array { + if (chunks.length === 1) return chunks[0]; + let length = 0; + for (const chunk of chunks) length += chunk.length; + const out = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +function toZlibOptions(options: PakoOptions = {}): zlib.ZlibOptions { + const { gzip: _gzip, raw: _raw, to: _to, ...zlibOptions } = options; + return zlibOptions; +} + +function normalizeOutput(out: Uint8Array, options?: PakoOptions): Uint8Array | string { + if (options?.to === "string") return UTF8_DECODER.decode(out); + return out; +} + +export function deflate(data: Data, options?: PakoOptions): Uint8Array | string { + const input = toBytes(data); + const zlibOptions = toZlibOptions(options); + const out = options?.gzip + ? zlib.gzipSync(input, zlibOptions) + : options?.raw + ? zlib.deflateRawSync(input, zlibOptions) + : zlib.deflateSync(input, zlibOptions); + return normalizeOutput(out, options); +} + +export function deflateRaw(data: Data, options?: PakoOptions): Uint8Array | string { + return normalizeOutput(zlib.deflateRawSync(toBytes(data), toZlibOptions(options)), options); +} + +export function gzip(data: Data, options?: PakoOptions): Uint8Array | string { + return normalizeOutput(zlib.gzipSync(toBytes(data), toZlibOptions(options)), options); +} + +export function inflate(data: Data, options?: PakoOptions): Uint8Array | string { + const input = toBytes(data); + const zlibOptions = toZlibOptions(options); + const out = options?.raw + ? zlib.inflateRawSync(input, zlibOptions) + : zlib.inflateSync(input, zlibOptions); + return normalizeOutput(out, options); +} + +export function inflateRaw(data: Data, options?: PakoOptions): Uint8Array | string { + return normalizeOutput(zlib.inflateRawSync(toBytes(data), toZlibOptions(options)), options); +} + +export function ungzip(data: Data, options?: PakoOptions): Uint8Array | string { + return normalizeOutput(zlib.gunzipSync(toBytes(data), toZlibOptions(options)), options); +} + +function isIncompleteInput(error: ZlibError): boolean { + return error.code === "Z_BUF_ERROR" || error.errno === zlib.constants.Z_BUF_ERROR; +} + +function pakoErrno(error: ZlibError): number { + return typeof error.errno === "number" ? error.errno : 1; +} + +export class Inflate { + err = 0; + msg = ""; + result: Uint8Array | string | undefined; + readonly strm: PakoStream = { avail_in: 0 }; + + readonly #chunks: Uint8Array[] = []; + readonly #options: PakoOptions; + + constructor(options: PakoOptions = {}) { + this.#options = options; + } + + push(data: Data, mode?: boolean | number): boolean { + if (this.result !== undefined) return true; + + const chunk = toBytes(data); + if (chunk.length > 0) this.#chunks.push(chunk); + const input = concat(this.#chunks); + + try { + const out = (this.#options.raw + ? zlib.inflateRawSync(input, { ...toZlibOptions(this.#options), info: true }) + : zlib.inflateSync(input, { + ...toZlibOptions(this.#options), + info: true, + })) as unknown as ZlibInfoResult; + this.result = normalizeOutput(out.buffer, this.#options); + const consumed = out.engine?.bytesWritten ?? input.length; + this.strm.avail_in = Math.max(0, input.length - consumed); + this.err = 0; + this.msg = ""; + return true; + } catch (cause) { + const error = cause as ZlibError; + if (!mode && isIncompleteInput(error)) return true; + this.err = pakoErrno(error); + this.msg = error.message; + return false; + } + } +} + +export class Deflate { + err = 0; + msg = ""; + result: Uint8Array | string | undefined; + readonly strm: PakoStream = { avail_in: 0 }; + + readonly #chunks: Uint8Array[] = []; + readonly #options: PakoOptions; + + constructor(options: PakoOptions = {}) { + this.#options = options; + } + + push(data: Data, mode?: boolean | number): boolean { + if (this.result !== undefined) return true; + + const chunk = toBytes(data); + if (chunk.length > 0) this.#chunks.push(chunk); + if (!mode) return true; + + try { + this.result = deflate(concat(this.#chunks), this.#options); + this.err = 0; + this.msg = ""; + this.strm.avail_in = 0; + return true; + } catch (cause) { + const error = cause as ZlibError; + this.err = pakoErrno(error); + this.msg = error.message; + return false; + } + } +} + +const pako = { + Deflate, + deflate, + deflateRaw, + gzip, + Inflate, + inflate, + inflateRaw, + ungzip, + ...zlib.constants, +}; + +export const { + Z_NO_FLUSH, + Z_PARTIAL_FLUSH, + Z_SYNC_FLUSH, + Z_FULL_FLUSH, + Z_FINISH, + Z_BLOCK, + Z_OK, + Z_STREAM_END, + Z_NEED_DICT, + Z_ERRNO, + Z_STREAM_ERROR, + Z_DATA_ERROR, + Z_MEM_ERROR, + Z_BUF_ERROR, + Z_VERSION_ERROR, +} = zlib.constants; + +export default pako; diff --git a/packages/computer/src/index.ts b/packages/computer/src/index.ts index da33c58..3ed3a2f 100644 --- a/packages/computer/src/index.ts +++ b/packages/computer/src/index.ts @@ -100,6 +100,7 @@ export { type SyncRetryScheduler, type ThinkWorkspaceCompatibility, Workspace, + type WorkspaceGitFactory, type WorkspaceOptions, type WorkspaceRetryPendingSyncResult, } from "./workspace.js"; diff --git a/packages/computer/src/stub.test.ts b/packages/computer/src/stub.test.ts index d81c892..e11d0d4 100644 --- a/packages/computer/src/stub.test.ts +++ b/packages/computer/src/stub.test.ts @@ -16,6 +16,7 @@ import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; import { beforeAll, describe, expect, it } from "vitest"; import type { BackendHandle, WorkspaceBackend } from "./backend.js"; +import { createGitClient } from "./git/index.js"; import type { EagerMount, MountWriteAPI } from "./mounts/types.js"; import type { WorkspaceRuntimeExecHandle, WorkspaceRuntimeResult } from "./runtime/types.js"; import { decodeRuntimeEvents } from "./runtime/wire.js"; @@ -113,7 +114,7 @@ function snapshotOf(names: string[]): Record { async function withStub( fn: (ws: Workspace) => T | Promise, - options?: Pick[0], "assets" | "code"> & { + options?: Pick[0], "assets" | "code" | "git"> & { backend?: WorkspaceBackend; }, ): Promise { @@ -122,6 +123,7 @@ async function withStub( backends: [options?.backend ?? backend()], assets: options?.assets, code: options?.code, + git: options?.git ?? createGitClient(), }); try { await ws.ready(); diff --git a/packages/computer/src/workspace.test.ts b/packages/computer/src/workspace.test.ts index e775d1f..7d60c07 100644 --- a/packages/computer/src/workspace.test.ts +++ b/packages/computer/src/workspace.test.ts @@ -2,6 +2,7 @@ import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; import { describe, expect, it, vi } from "vitest"; import type { BackendHandle, WorkspaceBackend } from "./backend.js"; +import { createGitClient } from "./git/index.js"; import type { WorkspaceModuleBackend } from "./runtime/types.js"; import { WorkspaceTransportError } from "./transport-failure.js"; import { type ThinkWorkspaceCompatibility, Workspace } from "./workspace.js"; @@ -641,21 +642,25 @@ describe("Workspace backend selection", () => { }); describe("workspace.git", () => { - // workspace.git is a lazy property accessor that doesn't - // require a backend — every supported subcommand reads and - // writes through the local SQLite-backed VFS. We pin three - // contracts here: (1) repeat access returns the same client - // so the pack/index cache is shared, (2) constructing the - // client doesn't fire the dynamic imports of isomorphic-git - // / diff, (3) the surface is available on a Workspace with - // no backend configured. - it("returns the same client across calls", () => { + // workspace.git is an opt-in property accessor. The default + // Workspace graph stays free of the git implementation; callers + // that need git pass the factory from @cloudflare/computer/git. + // The client is still lazy once configured, so touching the + // getter does not load isomorphic-git / diff, and it works on a + // filesystem-only Workspace because every subcommand reads and + // writes through the local SQLite-backed VFS. + it("throws a clear error when git is not configured", () => { const ws = new Workspace({ storage: makeStorage() }); + expect(() => ws.git).toThrow(/Workspace git is not configured/); + }); + + it("returns the same configured client across calls", () => { + const ws = new Workspace({ storage: makeStorage(), git: createGitClient() }); expect(ws.git).toBe(ws.git); }); - it("is available with no backend configured", async () => { - const ws = new Workspace({ storage: makeStorage() }); + it("is available with no backend configured when a git factory is passed", async () => { + const ws = new Workspace({ storage: makeStorage(), git: createGitClient() }); await ws.ready(); // help is hermetic — no dynamic imports, no fs touches. const res = await ws.git.cli({ argv: ["help"] }); @@ -663,8 +668,8 @@ describe("Workspace backend selection", () => { expect(res.stdout).toContain("usage: git"); }); - it("`git version` runs end-to-end without ready()", async () => { - const ws = new Workspace({ storage: makeStorage() }); + it("`git version` runs end-to-end without ready() when configured", async () => { + const ws = new Workspace({ storage: makeStorage(), git: createGitClient() }); const res = await ws.git.cli({ argv: ["version"] }); expect(res.exitCode).toBe(0); expect(res.stdout).toContain("@cloudflare/computer"); diff --git a/packages/computer/src/workspace.ts b/packages/computer/src/workspace.ts index 7d6b0b3..3e84b29 100644 --- a/packages/computer/src/workspace.ts +++ b/packages/computer/src/workspace.ts @@ -27,7 +27,7 @@ import { } from "./artifacts/index.js"; import type { AssetsClient } from "./assets/index.js"; import type { BackendHandle, WorkspaceBackend } from "./backend.js"; -import { createGitClient, type GitClient, type GitIdentity } from "./git/index.js"; +import type { GitClient, GitClientFactory, GitIdentity } from "./git/index.js"; import { MountIndex } from "./mounts/index.js"; import { buildMountRegistry, type MountValue } from "./mounts/registry.js"; import type { Mount } from "./mounts/types.js"; @@ -124,10 +124,16 @@ export interface WorkspaceOptions { retryScheduler?: SyncRetryScheduler; retry?: SyncRetryOptions; + // Optional git client factory. Omit it to keep the default + // Workspace graph free of isomorphic-git; pass createGitClient() + // from @cloudflare/computer/git when the caller needs + // workspace.git or the worker backend's built-in git command. + git?: WorkspaceGitFactory; + // Default identity used by commit-producing git subcommands // when neither the call site nor the relevant `GIT_AUTHOR_*` / // `GIT_COMMITTER_*` env vars supply one. Threaded through to - // `createGitClient` on first access to `workspace.git`. + // the configured git factory on first access to `workspace.git`. defaultGitIdentity?: GitIdentity; // Optional assets publisher used by WorkspaceStub and the worker @@ -180,6 +186,22 @@ export type ThinkWorkspaceFilesystem = Pick< "find" | "mkdir" | "readFile" | "readdir" | "rm" | "stat" | "writeFile" >; +export type WorkspaceGitFactory = GitClientFactory; + +const GIT_NOT_CONFIGURED_MESSAGE = + "Workspace git is not configured. Import createGitClient from " + + "@cloudflare/computer/git and pass createGitClient() as WorkspaceOptions.git."; + +const DISABLED_GIT_CLIENT = new Proxy( + {}, + { + get(_target, property) { + if (property === "then") return undefined; + return () => Promise.reject(new Error(GIT_NOT_CONFIGURED_MESSAGE)); + }, + }, +) as GitClient; + export class Workspace { readonly #db: Database; readonly #fs: WorkspaceFilesystem; @@ -202,6 +224,7 @@ export class Workspace { readonly #retryMaxDelayMs: number; readonly #retryMaxAttempts: number; readonly #sessionId: string; + readonly #gitFactory: WorkspaceGitFactory | undefined; readonly #defaultGitIdentity: GitIdentity | undefined; readonly #useThink: boolean; readonly #assets: AssetsClient | undefined; @@ -264,6 +287,7 @@ export class Workspace { "maxAttempts", ); this.#sessionId = options.sessionId ?? ""; + this.#gitFactory = options.git; this.#defaultGitIdentity = options.defaultGitIdentity; this.#useThink = options.useThink ?? false; this.#artifacts = options.artifacts @@ -381,18 +405,21 @@ export class Workspace { return this.#assets; } - // Git facade. Available immediately and does not require a + // Git facade. Opt-in so the default Workspace graph does not + // carry isomorphic-git. When configured, it does not require a // backend — every supported subcommand reads and writes through - // the local SQLite-backed VFS. The dynamic imports for - // isomorphic-git / diff are deferred until the first method on - // the returned client is awaited; touching `workspace.git` - // itself is cheap. + // the local SQLite-backed VFS. The configured factory decides + // how the heavy git implementation is loaded. // // Memoised on a private field so repeated callers share the - // pack/index cache and the resolved peer-dep modules. + // pack/index cache and resolved modules from the configured + // implementation. get git(): GitClient { + if (!this.#gitFactory) { + throw new Error(GIT_NOT_CONFIGURED_MESSAGE); + } if (!this.#git) { - this.#git = createGitClient({ + this.#git = this.#gitFactory({ ws: this, defaultIdentity: this.#defaultGitIdentity, }); @@ -785,7 +812,7 @@ export class Workspace { db: this.#db, waitUntil: this.#waitUntil, fs: this.#fs, - git: this.git, + git: this.#gitFactory ? this.git : DISABLED_GIT_CLIENT, artifacts: this.#artifacts, }), ) diff --git a/packages/computer/tests/script-runner-worker.ts b/packages/computer/tests/script-runner-worker.ts index 4a2f921..df70595 100644 --- a/packages/computer/tests/script-runner-worker.ts +++ b/packages/computer/tests/script-runner-worker.ts @@ -1,5 +1,6 @@ import { DurableObject, RpcTarget, WorkerEntrypoint } from "cloudflare:workers"; import { IsolateJavaScriptBackend } from "../src/backends/javascript/index.js"; +import { createGitClient } from "../src/git/index.js"; import type { DurableObjectStorageLike, WorkspaceRuntimeValue, @@ -20,6 +21,7 @@ export class HostDO extends DurableObject { this.#workspace = new Workspace({ storage: ctx.storage as unknown as DurableObjectStorageLike, waitUntil: ctx.waitUntil.bind(ctx), + git: createGitClient(), backends: [ new IsolateJavaScriptBackend({ loader: env.LOADER, diff --git a/packages/computer/vitest.config.ts b/packages/computer/vitest.config.ts index 1c0687d..d892792 100644 --- a/packages/computer/vitest.config.ts +++ b/packages/computer/vitest.config.ts @@ -21,6 +21,12 @@ export default defineConfig({ find: "cloudflare:workers", replacement: resolve(__dirname, "test-helpers/cloudflare-workers-stub.ts"), }, + // Match the package build: isomorphic-git's pako import is + // replaced with the Workers node:zlib-backed shim. + { + find: "pako", + replacement: resolve(__dirname, "src/git/pako-zlib-shim.ts"), + }, ], }, test: {