From 83140a06523b711c7339df09989b57b25c5a59af Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:39:04 +0100 Subject: [PATCH 01/12] fix: batch hasObjects probes for DO SQLite --- packages/dofs/src/sync/fetch.test.ts | 17 ++++++++++++++++- packages/dofs/src/sync/fetch.ts | 8 ++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/dofs/src/sync/fetch.test.ts b/packages/dofs/src/sync/fetch.test.ts index 01b295e2..b979020e 100644 --- a/packages/dofs/src/sync/fetch.test.ts +++ b/packages/dofs/src/sync/fetch.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; import { withDB } from "../fs/with-db.js"; -import { createFileSync, writeFile, writeRangeSync } from "../fs/writeFile.js"; +import { createFileSync, chunksOf, writeFile, writeRangeSync } from "../fs/writeFile.js"; +import { stageBlob } from "./blobs.js"; import { coalesceChanges } from "./coalesce.js"; import { fetchChanges, fetchObjects, hasObjects } from "./fetch.js"; @@ -129,6 +130,20 @@ describe("hasObjects", () => { }); }); + it("probes more objects than Durable Object SQLite accepts in one query", async () => { + await withDB(async (db) => { + const hashes: Uint8Array[] = []; + for (let i = 0; i < 101; i++) { + const bytes = new TextEncoder().encode(`object-${i}`); + const hash = chunksOf(bytes)[0].hash; + stageBlob(db, hash, bytes, i); + hashes.push(hash); + } + + expect(hasObjects(db, hashes)).toEqual(hashes); + }); + }); + it("preserves input order and duplicates across mixed inputs", async () => { await withDB(async (db) => { await writeFile(db, "/a.txt", "alpha", {}, () => 1); diff --git a/packages/dofs/src/sync/fetch.ts b/packages/dofs/src/sync/fetch.ts index 317540fe..84d62b2c 100644 --- a/packages/dofs/src/sync/fetch.ts +++ b/packages/dofs/src/sync/fetch.ts @@ -32,10 +32,10 @@ function toHex(bytes: Uint8Array): string { return out; } -// Largest hash list bound into one IN (…) probe. Comfortably under -// SQLite's bound-parameter limit, so a large probe splits into a few -// index-backed lookups instead of one oversized statement. -const PROBE_BATCH = 256; +// Largest hash list bound into one IN (…) probe. Durable Object +// SQLite accepts at most 100 bound parameters per query, so keep +// each probe within that platform limit. +const PROBE_BATCH = 100; // Subset-test the input hashes against complete local objects. A // metadata row alone is not enough: the payload must exist and its From 5e2057df8a592582b522a368033da338a0486598 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:39:04 +0100 Subject: [PATCH 02/12] fix: skip empty working tree diff entries --- packages/computer/src/git/diff.test.ts | 22 ++++++++++++++++++++++ packages/computer/src/git/diff.ts | 1 + 2 files changed, 23 insertions(+) diff --git a/packages/computer/src/git/diff.test.ts b/packages/computer/src/git/diff.test.ts index 02cf365c..9746f69f 100644 --- a/packages/computer/src/git/diff.test.ts +++ b/packages/computer/src/git/diff.test.ts @@ -36,6 +36,12 @@ async function commitFile(path: string, content: string, message: string): Promi return git.commit({ fs: memfs, dir: DIR, message, author: AUTHOR }); } +async function stageThenRemove(path: string): Promise { + await memfs.promises.writeFile(`${DIR}/${path}`, "hello\n"); + await git.add({ fs: memfs, dir: DIR, filepath: path }); + await memfs.promises.unlink(`${DIR}/${path}`); +} + async function runDiff(opts: { ref?: string } = {}): Promise { return diffWith({ git: isomorphicGit, @@ -57,6 +63,14 @@ describe("diffWith (real isomorphic-git + memfs)", () => { expect(await runDiff()).toBe(""); }); + it("returns '' for a staged addition removed from the working tree", async () => { + await init(); + await commitFile("base.txt", "base\n", "init"); + await stageThenRemove("added.txt"); + + expect(await runDiff()).toBe(""); + }); + it("returns '' when the working tree matches HEAD", async () => { await init(); await commitFile("a.txt", "hello\n", "init"); @@ -274,6 +288,14 @@ describe("diffSummaryWith (real isomorphic-git + memfs)", () => { }); } + it("returns an empty list for a staged addition removed from the working tree", async () => { + await init(); + await commitFile("base.txt", "base\n", "init"); + await stageThenRemove("added.txt"); + + expect(await summary()).toEqual([]); + }); + it("returns an empty list for a clean working tree", async () => { await init(); await commitFile("a.txt", "hello\n", "init"); diff --git a/packages/computer/src/git/diff.ts b/packages/computer/src/git/diff.ts index 19d9c1da..a164e25c 100644 --- a/packages/computer/src/git/diff.ts +++ b/packages/computer/src/git/diff.ts @@ -181,6 +181,7 @@ async function collectDiffEntries(opts: DiffWithDeps): Promise { : ""; const newText = workdirStatus === 2 ? await readWorkdirAsText(opts.readFile, dir, filepath) : ""; + if (oldText === newText) continue; // headStatus 0 -> not in the base -> added. workdirStatus 0 // -> gone from the working tree -> deleted. Otherwise it's a // content change. From 9c8d520fc32cd11eca8b1dcc43c2e4659b44db7c Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:39:04 +0100 Subject: [PATCH 03/12] fix: ignore wrangler dev vars --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 56157203..6aaf493b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,9 @@ packages/computer/src/backends/worker-shell/generated/ # blanket dist/ rule above; listed here for discoverability. packages/computer-computerd-*/bin/computerd packages/computer/dist/bin/computerd-* + +# Local secrets for `wrangler dev`. Examples document putting +# Cloudflare API credentials in .dev.vars files that wrangler reads +# from the example directory; they must never be committed. +.dev.vars +**/.dev.vars From b5f023f7e06d4e9163f581209175918c7ce77615 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:39:04 +0100 Subject: [PATCH 04/12] docs: align capnweb wire contract --- docs/08_capnweb_interface.md | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/docs/08_capnweb_interface.md b/docs/08_capnweb_interface.md index 6ec45c70..e919e6dc 100644 --- a/docs/08_capnweb_interface.md +++ b/docs/08_capnweb_interface.md @@ -16,9 +16,7 @@ alternative). The interface served is `WorkspaceRPC`, defined in against the computerd's `/ws` endpoint, with `/api` available as an HTTP-batch alternative (single POST per call) for callers that can't hold a socket. Default port is `45678`; it will become a - build-time variable so hosts can pin a non-default port. The stale - `/rpc` comment at `packages/rpc/src/client.ts:18` is scheduled for - cleanup. + build-time variable so hosts can pin a non-default port. - **Framing.** capnweb text frames. Binary frames are unsupported. **(planned)** the server will fail the session loudly on the first binary message; today the behaviour is unspecified. @@ -108,9 +106,7 @@ interface SyncRPC { // Container → DO direction of object transfer. Throws // EUNKNOWN_HASH if any hash is unknown — callers must dedupe - // and probe first. (planned: today the code returns an empty - // payload for missing hashes; EUNKNOWN_HASH via - // createWorkspaceError is the deferred fix.) + // and probe first. fetchObjects(hashes: Uint8Array[]): ReadableStream<{ hash: Uint8Array; bytes: Uint8Array }>; @@ -199,12 +195,12 @@ interface ShellRPC { type ExecEvent = | { id: string; seq: number; name: "stdout"; value: Uint8Array } | { id: string; seq: number; name: "stderr"; value: Uint8Array } - | { id: string; seq: number; name: "exit"; value: number; result?: unknown }; + | { id: string; seq: number; name: "exit"; code: number; result?: unknown }; ``` -The `exit` frame carries the process exit code and, for a callable +The `exit` frame carries the process exit code on `code` and, for a callable backend that ran to a zero exit, the structured return value on -`result`. The value and the exit code settle together, so a single +`result`. The result and the exit code settle together, so a single terminal frame carries both rather than splitting them across two frames. Command backends never set `result`. @@ -313,14 +309,13 @@ type WireErrorCode = type WireError = { code: WireErrorCode; message: string; - detail?: unknown; }; ``` | Code | Meaning | | --- | --- | | `ENOENT` | Path does not exist on the receiver (covers ignored paths, which are invisible to `Workspace.fs`), or `getExec` / `disposeExec` referenced an unknown id. | -| `EUNKNOWN_HASH` | **(reserved, planned)** `fetchObjects` or `pushObjects` referenced a hash the receiver has no record of. Reserved in `WireErrorCode` but not raised today; `pushObjects` should throw it via `createWorkspaceError`. | +| `EUNKNOWN_HASH` | `fetchObjects` or `pushObjects` referenced a hash the receiver has no record of; raised via `createWorkspaceError`. | | `EEXEC_BUSY` | `exec` was called with an `id` that's already in use by a live run. | | `ELOG_TRUNCATED` | `getExec` resume point is older than the retained log. | | `ESHUTDOWN` | **(reserved)** Server is shutting down; reconnect after the next boot. Not raised today. | From 402338dad18f7f5c85723ef48233179ea2121aff Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:39:04 +0100 Subject: [PATCH 05/12] fix: track RPC stubs by identity --- packages/rpc/src/debug.ts | 10 +++++++--- packages/rpc/tests/debug.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 packages/rpc/tests/debug.test.ts diff --git a/packages/rpc/src/debug.ts b/packages/rpc/src/debug.ts index 5aa9e440..0ac60720 100644 --- a/packages/rpc/src/debug.ts +++ b/packages/rpc/src/debug.ts @@ -1,8 +1,8 @@ // Stub leak tracking. Gated on the CAPNWEB_TRACK_STUBS env flag so // production paths pay nothing. Every RpcTarget we own opts in by -// calling `trackStub(this)` in its constructor; capnweb invokes -// `[Symbol.dispose]` when the last remote stub for that target is -// disposed, which is where we decrement. +// calling `trackStub(this)` in its constructor; capnweb may invoke +// `[Symbol.dispose]` more than once for a shared target as sessions +// end, so repeated disposals for the same object are ignored. // // The point is *measurement*, not enforcement: snapshot() returns the // per-class live count so a soak script can assert "after a quiet @@ -35,15 +35,19 @@ export function enableStubTracking(): void { } const counters = new Map(); +const trackedTargets = new WeakSet(); export function trackStub(target: object): void { if (!trackingEnabled) return; + if (trackedTargets.has(target)) return; + trackedTargets.add(target); const name = target.constructor?.name ?? "anonymous"; counters.set(name, (counters.get(name) ?? 0) + 1); } export function untrackStub(target: object): void { if (!trackingEnabled) return; + if (!trackedTargets.delete(target)) return; const name = target.constructor?.name ?? "anonymous"; const current = counters.get(name) ?? 0; if (current <= 1) counters.delete(name); diff --git a/packages/rpc/tests/debug.test.ts b/packages/rpc/tests/debug.test.ts new file mode 100644 index 00000000..83ebd332 --- /dev/null +++ b/packages/rpc/tests/debug.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { enableStubTracking, stubSnapshot, trackStub, untrackStub } from "../src/debug.js"; + +describe("stub leak tracking", () => { + it("counts target identities and ignores repeated disposal", () => { + enableStubTracking(); + + class DebugTrackerTarget {} + + const first = new DebugTrackerTarget(); + const second = new DebugTrackerTarget(); + + trackStub(first); + trackStub(second); + expect(stubSnapshot().DebugTrackerTarget).toBe(2); + + untrackStub(first); + expect(stubSnapshot().DebugTrackerTarget).toBe(1); + + untrackStub(first); + expect(stubSnapshot().DebugTrackerTarget).toBe(1); + + untrackStub(second); + expect(stubSnapshot().DebugTrackerTarget).toBeUndefined(); + }); +}); From b1d02555869e5233d46ab998d4d43df9f6522275 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:39:04 +0100 Subject: [PATCH 06/12] fix: render git log dates in author timezone --- packages/computer/src/git/cli.test.ts | 19 ++++++++++++++++--- packages/computer/src/git/cli.ts | 10 +++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/computer/src/git/cli.test.ts b/packages/computer/src/git/cli.test.ts index 13135fe8..c3d903c0 100644 --- a/packages/computer/src/git/cli.test.ts +++ b/packages/computer/src/git/cli.test.ts @@ -994,7 +994,7 @@ describe("runGitCli — commit argv parsing", () => { }); describe("runGitCli — log argv parsing", () => { - const sample = (oid: string, msg: string): CommitView => ({ + const sample = (oid: string, msg: string, timezoneOffset = 0): CommitView => ({ oid, message: msg, tree: "", @@ -1003,13 +1003,13 @@ describe("runGitCli — log argv parsing", () => { name: "A", email: "a@x", timestamp: 1_700_000_000, - timezoneOffset: 0, + timezoneOffset, }, committer: { name: "A", email: "a@x", timestamp: 1_700_000_000, - timezoneOffset: 0, + timezoneOffset, }, }); @@ -1036,10 +1036,23 @@ describe("runGitCli — log argv parsing", () => { expect(res.exitCode).toBe(0); expect(res.stdout).toContain(`commit ${"a".repeat(40)}`); expect(res.stdout).toContain("Author: A "); + expect(res.stdout).toContain("Date: 2023-11-14 22:13:20 +0000"); expect(res.stdout).toContain(" hello"); expect(res.stdout).toContain(" world"); }); + it("shifts full-form Date into the author's timezone", async () => { + const { client } = fakeClient( + {}, + { + log: () => [sample("a".repeat(40), "hello", -330)], + }, + ); + const res = await runGitCli(client, { argv: ["log"] }); + expect(res.exitCode).toBe(0); + expect(res.stdout).toContain("Date: 2023-11-15 03:43:20 +0530"); + }); + it("-n forwards as depth", async () => { const { client, calls } = fakeClient(); await runGitCli(client, { argv: ["log", "-n", "3"] }); diff --git a/packages/computer/src/git/cli.ts b/packages/computer/src/git/cli.ts index 8b62d372..702c03df 100644 --- a/packages/computer/src/git/cli.ts +++ b/packages/computer/src/git/cli.ts @@ -826,12 +826,12 @@ function formatLogFull(commits: CommitView[]): string { } function formatGitTimestamp(timestamp: number, timezoneOffset: number): string { - // isomorphic-git stores timezoneOffset as minutes east of UTC, - // but git's wire format is `git log`'s output uses minutes - // west (the inverse). The two are negatives of each other; - // pick the convention real git users see. - const d = new Date(timestamp * 1000); + // isomorphic-git stores timezoneOffset using Date.getTimezoneOffset's + // minutes-west-of-UTC convention. Git log renders the inverse offset + // (`+0530` for timezoneOffset -330) and shifts the wall clock into + // that zone before appending it. const offsetMinutes = -timezoneOffset; + const d = new Date((timestamp + offsetMinutes * 60) * 1000); const sign = offsetMinutes >= 0 ? "+" : "-"; const abs = Math.abs(offsetMinutes); const hh = String(Math.floor(abs / 60)).padStart(2, "0"); From 0c8ea8e6ef9d7e9b6a187a6b0680bd9aff94cf3d Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:39:04 +0100 Subject: [PATCH 07/12] fix: report staged deletions as clean worktree --- packages/computer/src/git/status.test.ts | 11 +++++++++++ packages/computer/src/git/status.ts | 9 ++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/computer/src/git/status.test.ts b/packages/computer/src/git/status.test.ts index a0089fca..46a18b99 100644 --- a/packages/computer/src/git/status.test.ts +++ b/packages/computer/src/git/status.test.ts @@ -103,6 +103,17 @@ describe("statusWith — derived XY codes", () => { ]); }); + it("staged delete -> 'D '", async () => { + await init(); + await commit("gone.txt", "x\n", "init"); + await memfs.promises.unlink(`${DIR}/gone.txt`); + await git.remove({ fs: memfs, dir: DIR, filepath: "gone.txt" }); + + expect(await statusWith({ git: isogit, fs: memfs, dir: DIR })).toEqual([ + { path: "gone.txt", index: "D", worktree: " " }, + ]); + }); + it("multiple entries are sorted lexicographically", async () => { await init(); await commit("base.txt", "x\n", "init"); diff --git a/packages/computer/src/git/status.ts b/packages/computer/src/git/status.ts index 96a7dad8..f97aa666 100644 --- a/packages/computer/src/git/status.ts +++ b/packages/computer/src/git/status.ts @@ -103,12 +103,11 @@ function indexCode(head: number, stage: number): StatusEntry["index"] { } // Derive the Y (workdir-vs-index) column. -// workdir absent, anything -> 'D' (deleted in workdir) -// not in HEAD, not in stage -> '?' (untracked) -// workdir differs from stage -> 'M' -// workdir same as stage -> ' ' +// workdir absent, stage present -> 'D' (deleted in workdir) +// not in HEAD, not in stage -> '?' (untracked) +// workdir differs from stage -> 'M' +// workdir same as stage -> ' ' function worktreeCode(head: number, workdir: number, stage: number): StatusEntry["worktree"] { - if (workdir === 0 && stage === 0 && head === 1) return "D"; if (workdir === 0 && stage > 0) return "D"; if (head === 0 && stage === 0 && workdir > 0) return "?"; // workdirStatus 2 means "differs from HEAD"; stageStatus 3 means From a2b842e0eeba3d46019fb6af1be789798e8f573c Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:39:04 +0100 Subject: [PATCH 08/12] fix: update example computerd image tag --- docs/README.md | 2 +- examples/container/Dockerfile | 2 +- examples/think-compare-runtimes/Dockerfile.workspace | 2 +- examples/think/Dockerfile | 2 +- examples/tutorial/Dockerfile | 2 +- packages/computer-computerd-linux-x64/README.md | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/README.md b/docs/README.md index ffdf2c5b..74ef2edb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -63,7 +63,7 @@ copies the prebuilt binary out of the public GHCR image and into a thin Debian base: ```dockerfile -FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.0-alpha.1 AS computerd +FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.1 AS computerd FROM debian:stable-slim diff --git a/examples/container/Dockerfile b/examples/container/Dockerfile index 52684b8e..964d7914 100644 --- a/examples/container/Dockerfile +++ b/examples/container/Dockerfile @@ -14,7 +14,7 @@ # back to the userspace shim transparently. -FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.0-alpha.1 AS computerd +FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.1 AS computerd FROM debian:stable-slim diff --git a/examples/think-compare-runtimes/Dockerfile.workspace b/examples/think-compare-runtimes/Dockerfile.workspace index b88a5646..aadc91d8 100644 --- a/examples/think-compare-runtimes/Dockerfile.workspace +++ b/examples/think-compare-runtimes/Dockerfile.workspace @@ -12,7 +12,7 @@ # that do not expose /dev/fuse. -FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.0-alpha.1 AS computerd +FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.1 AS computerd FROM --platform=linux/amd64 node:22-trixie-slim diff --git a/examples/think/Dockerfile b/examples/think/Dockerfile index fbfc8062..839880b4 100644 --- a/examples/think/Dockerfile +++ b/examples/think/Dockerfile @@ -9,7 +9,7 @@ # surface reads and writes. -FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.0-alpha.1 AS computerd +FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.1 AS computerd FROM debian:stable-slim diff --git a/examples/tutorial/Dockerfile b/examples/tutorial/Dockerfile index 9d860f62..bb5f086b 100644 --- a/examples/tutorial/Dockerfile +++ b/examples/tutorial/Dockerfile @@ -8,7 +8,7 @@ # through the Workspace is on disk for pandoc to read, and the PDF # pandoc writes syncs back to the durable object. -FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.0-alpha.1 AS computerd +FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.1 AS computerd FROM debian:stable-slim diff --git a/packages/computer-computerd-linux-x64/README.md b/packages/computer-computerd-linux-x64/README.md index b195d64a..fb98b546 100644 --- a/packages/computer-computerd-linux-x64/README.md +++ b/packages/computer-computerd-linux-x64/README.md @@ -20,7 +20,7 @@ builds the binary, stages it into `bin/computerd`, and publishes the image instead: ```dockerfile -FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.0-alpha.1 AS computerd +FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.1 AS computerd FROM debian:stable-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ From e6cf08005991bb1fcf3dac6e37d5a7fc4169c76f Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:39:04 +0100 Subject: [PATCH 09/12] computer: Keep empty file diff summaries --- packages/computer/src/git/diff.test.ts | 36 ++++++++++++++++++++++++++ packages/computer/src/git/diff.ts | 16 ++++++------ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/computer/src/git/diff.test.ts b/packages/computer/src/git/diff.test.ts index 9746f69f..923744fd 100644 --- a/packages/computer/src/git/diff.test.ts +++ b/packages/computer/src/git/diff.test.ts @@ -319,6 +319,14 @@ describe("diffSummaryWith (real isomorphic-git + memfs)", () => { expect(entries).toEqual([{ path: "b.txt", status: "A", insertions: 2, deletions: 0 }]); }); + it("reports an added empty file", async () => { + await init(); + await commitFile("a.txt", "kept\n", "init"); + await memfs.promises.writeFile(`${DIR}/empty.txt`, ""); + const entries = await summary(); + expect(entries).toEqual([{ path: "empty.txt", status: "A", insertions: 0, deletions: 0 }]); + }); + it("reports a deleted file", async () => { await init(); await commitFile("gone.txt", "a\nb\n", "init"); @@ -327,6 +335,14 @@ describe("diffSummaryWith (real isomorphic-git + memfs)", () => { expect(entries).toEqual([{ path: "gone.txt", status: "D", insertions: 0, deletions: 2 }]); }); + it("reports a deleted empty file", async () => { + await init(); + await commitFile("empty.txt", "", "init"); + await memfs.promises.unlink(`${DIR}/empty.txt`); + const entries = await summary(); + expect(entries).toEqual([{ path: "empty.txt", status: "D", insertions: 0, deletions: 0 }]); + }); + it("reports added and deleted files between two commits", async () => { await init(); const first = await commitFile("keep.txt", "keep\n", "v1"); @@ -337,6 +353,26 @@ describe("diffSummaryWith (real isomorphic-git + memfs)", () => { expect(entries).toEqual([{ path: "new.txt", status: "A", insertions: 1, deletions: 0 }]); }); + it("reports empty file presence changes between two commits", async () => { + await init(); + const first = await commitFile("removed.txt", "", "v1"); + await memfs.promises.unlink(`${DIR}/removed.txt`); + await memfs.promises.writeFile(`${DIR}/added.txt`, ""); + await git.remove({ fs: memfs, dir: DIR, filepath: "removed.txt" }); + await git.add({ fs: memfs, dir: DIR, filepath: "added.txt" }); + const second = await git.commit({ + fs: memfs, + dir: DIR, + message: "empty changes", + author: AUTHOR, + }); + const entries = await summary({ ref: first, to: second }); + expect(entries).toEqual([ + { path: "added.txt", status: "A", insertions: 0, deletions: 0 }, + { path: "removed.txt", status: "D", insertions: 0, deletions: 0 }, + ]); + }); + it("counts content lines that begin with diff header prefixes", async () => { await init(); await commitFile("patch.txt", "-- old old\n", "v1"); diff --git a/packages/computer/src/git/diff.ts b/packages/computer/src/git/diff.ts index a164e25c..e71c7b5e 100644 --- a/packages/computer/src/git/diff.ts +++ b/packages/computer/src/git/diff.ts @@ -175,13 +175,13 @@ async function collectDiffEntries(opts: DiffWithDeps): Promise { if (workdirStatus === 1) continue; if (!pathFilter(filepath)) continue; - const oldText = - headStatus === 1 - ? await readBlobAsText(opts.git, opts.fs, dir, head, filepath, opts.cache) - : ""; - const newText = - workdirStatus === 2 ? await readWorkdirAsText(opts.readFile, dir, filepath) : ""; - if (oldText === newText) continue; + const oldExists = headStatus === 1; + const newExists = workdirStatus > 0; + const oldText = oldExists + ? await readBlobAsText(opts.git, opts.fs, dir, head, filepath, opts.cache) + : ""; + const newText = newExists ? await readWorkdirAsText(opts.readFile, dir, filepath) : ""; + if (oldExists === newExists && oldText === newText) continue; // headStatus 0 -> not in the base -> added. workdirStatus 0 // -> gone from the working tree -> deleted. Otherwise it's a // content change. @@ -222,7 +222,7 @@ async function collectRefToRef( ? await readBlobAsText(opts.git, opts.fs, dir, fromOid, filepath, opts.cache) : ""; const b = inTo ? await readBlobAsText(opts.git, opts.fs, dir, toOid, filepath, opts.cache) : ""; - if (a === b) continue; + if (inFrom === inTo && a === b) continue; const status: DiffEntry["status"] = !inFrom ? "A" : !inTo ? "D" : "M"; entries.push({ path: filepath, status, oldText: a, newText: b }); } From 01c66ccb9425e4c328ebcd84a59950e1a7919c14 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:39:04 +0100 Subject: [PATCH 10/12] docs: Correct pushObjects error contract --- docs/08_capnweb_interface.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/08_capnweb_interface.md b/docs/08_capnweb_interface.md index e919e6dc..c4ed867a 100644 --- a/docs/08_capnweb_interface.md +++ b/docs/08_capnweb_interface.md @@ -315,7 +315,7 @@ type WireError = { | Code | Meaning | | --- | --- | | `ENOENT` | Path does not exist on the receiver (covers ignored paths, which are invisible to `Workspace.fs`), or `getExec` / `disposeExec` referenced an unknown id. | -| `EUNKNOWN_HASH` | `fetchObjects` or `pushObjects` referenced a hash the receiver has no record of; raised via `createWorkspaceError`. | +| `EUNKNOWN_HASH` | `fetchObjects` referenced a hash the receiver has no record of; raised via `createWorkspaceError`. | | `EEXEC_BUSY` | `exec` was called with an `id` that's already in use by a live run. | | `ELOG_TRUNCATED` | `getExec` resume point is older than the retained log. | | `ESHUTDOWN` | **(reserved)** Server is shutting down; reconnect after the next boot. Not raised today. | From 0da19a40069d16beb833d55f6cc780294c6305e3 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:39:04 +0100 Subject: [PATCH 11/12] dofs: Cover hasObjects probe batching --- packages/dofs/src/sync/fetch.test.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/dofs/src/sync/fetch.test.ts b/packages/dofs/src/sync/fetch.test.ts index b979020e..57fd93f6 100644 --- a/packages/dofs/src/sync/fetch.test.ts +++ b/packages/dofs/src/sync/fetch.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import { withDB } from "../fs/with-db.js"; -import { createFileSync, chunksOf, writeFile, writeRangeSync } from "../fs/writeFile.js"; +import { chunksOf, createFileSync, writeFile, writeRangeSync } from "../fs/writeFile.js"; +import { initializeSchema } from "../schema/index.js"; +import { Database } from "../storage.js"; +import { SQLiteTestStorage } from "../testing.js"; +import type { DurableObjectStorageLike, SQLCursorLike } from "../types.js"; import { stageBlob } from "./blobs.js"; import { coalesceChanges } from "./coalesce.js"; import { fetchChanges, fetchObjects, hasObjects } from "./fetch.js"; @@ -130,8 +134,22 @@ describe("hasObjects", () => { }); }); - it("probes more objects than Durable Object SQLite accepts in one query", async () => { - await withDB(async (db) => { + it("probes more objects than Durable Object SQLite accepts in one query", () => { + const storage = new SQLiteTestStorage(); + const limitedStorage: DurableObjectStorageLike = { + sql: { + exec(query: string, ...bindings: unknown[]): SQLCursorLike { + if (bindings.length > 100) { + throw new Error(`too many SQLite bindings: ${bindings.length}`); + } + return storage.sql.exec(query, ...bindings); + }, + }, + transactionSync: (closure) => storage.transactionSync(closure), + }; + const db = new Database(limitedStorage); + initializeSchema(db, () => 1000); + try { const hashes: Uint8Array[] = []; for (let i = 0; i < 101; i++) { const bytes = new TextEncoder().encode(`object-${i}`); @@ -141,7 +159,9 @@ describe("hasObjects", () => { } expect(hasObjects(db, hashes)).toEqual(hashes); - }); + } finally { + storage.close(); + } }); it("preserves input order and duplicates across mixed inputs", async () => { From dd893df9bae35f97e3e3ce2ccb35169e21e4bb8f Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:39:05 +0100 Subject: [PATCH 12/12] changeset: Add quick fixes release note --- .changeset/quick-fixes-git-sync-rpc.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/quick-fixes-git-sync-rpc.md diff --git a/.changeset/quick-fixes-git-sync-rpc.md b/.changeset/quick-fixes-git-sync-rpc.md new file mode 100644 index 00000000..a73b75f3 --- /dev/null +++ b/.changeset/quick-fixes-git-sync-rpc.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/computer": patch +"@cloudflare/dofs": patch +"@cloudflare/computer-rpc": patch +--- + +Fix git diff/status/log edge cases, batch sync hash probes within Durable Object SQLite limits, and count tracked RPC targets by identity.