Repository navigation
Description
What happens
When a file is changed to new content of the same size, in the same whole second as the stat that git add stored in the index, the Workspace git client does not see the change:
git statusshows the file as unchanged.git add -Astages nothing, and the next commit keeps the old content.
An agent that edits a file soon after it commits, and then commits again, can push the old content without any error.
Versions
@cloudflare/computer0.11.2 (also seen on 0.10.0)@platformatic/vfs0.5.0- Node 26.12.1 for the reproduction below. We first saw it in workerd, in a Durable Object Workspace.
Reproduction
Three small files plus npm i @cloudflare/computer@0.11.2 @platformatic/vfs@0.5.0. The loader files only stand in for cloudflare:workers outside workerd. The storage is an in-memory node:sqlite stand-in for Durable Object SQL.
register.mjs
import { register } from "node:module";
register("./hooks.mjs", import.meta.url);hooks.mjs
export async function resolve(specifier, context, next) {
if (specifier === "cloudflare:workers")
return { url: new URL("./cloudflare-workers.mjs", import.meta.url).href, shortCircuit: true };
return next(specifier, context);
}cloudflare-workers.mjs
export class RpcTarget {}
export class DurableObject {}
export class WorkerEntrypoint {}
export const env = {};repro.mjs (run with node --import ./register.mjs repro.mjs)
import { DatabaseSync } from "node:sqlite";
import { Workspace } from "@cloudflare/computer";
import { createGitClient } from "@cloudflare/computer/git";
function memoryStorage() {
const db = new DatabaseSync(":memory:");
return {
sql: {
exec(query, ...params) {
if (params.length === 0 && query.split(";").filter((s) => s.trim()).length > 1) {
db.exec(query);
return { toArray: () => [] };
}
const rows = db.prepare(query).all(...params);
return { toArray: () => rows };
},
},
transactionSync(fn) {
db.exec("BEGIN");
try { const result = fn(); db.exec("COMMIT"); return result; }
catch (error) { db.exec("ROLLBACK"); throw error; }
},
};
}
async function run(waitMs) {
const workspace = new Workspace({
storage: memoryStorage(),
git: createGitClient(),
defaultGitIdentity: { name: "Repro", email: "repro@example.com" },
});
const git = async (...argv) => {
const result = await workspace.git.cli({ argv, cwd: "/" });
if (result.exitCode !== 0) throw new Error(`git ${argv.join(" ")}: ${result.stderr}`);
return result.stdout;
};
await git("init");
await workspace.fs.writeFile("/color.txt", "color = red\n");
await git("add", "-A");
await git("commit", "-m", "red");
await new Promise((resolve) => setTimeout(resolve, waitMs));
await workspace.fs.writeFile("/color.txt", "color = tan\n"); // same size: 12 bytes
const status = await git("status", "--porcelain");
await git("add", "-A");
await workspace.git.cli({ argv: ["commit", "-m", "tan"], cwd: "/" });
const head = await workspace.git.revParse({ dir: "/", ref: "HEAD" });
const committed = await git("cat-file", "-p", `${head}:color.txt`);
console.log(`wait ${String(waitMs).padStart(4)} ms | status ${JSON.stringify(status).padEnd(18)} | HEAD:color.txt ${JSON.stringify(committed)}`);
}
for (const waitMs of [0, 0, 0, 200, 500, 1100, 2100]) await run(waitMs);Output:
wait 0 ms | status "" | HEAD:color.txt "color = red\n"
wait 0 ms | status "" | HEAD:color.txt "color = red\n"
wait 0 ms | status "" | HEAD:color.txt "color = red\n"
wait 200 ms | status "" | HEAD:color.txt "color = red\n"
wait 500 ms | status "" | HEAD:color.txt "color = red\n"
wait 1100 ms | status "1 M color.txt\n" | HEAD:color.txt "color = tan\n"
wait 2100 ms | status "1 M color.txt\n" | HEAD:color.txt "color = tan\n"
Expected: every row shows color.txt as modified, and HEAD:color.txt is "color = tan\n".
Cause
The bundled isomorphic-git decides that a file is unchanged when its stat matches the index entry (compareStats in dist/shared-*.js). It compares mtimeSeconds and ctimeSeconds in whole seconds (SecondsNanoseconds floors mtimeMs), plus size, mode and ino. It does not apply git's racy-git rule: an entry whose mtime is not older than the index file must have its content hashed again.
The Workspace filesystem gives millisecond times, so a same-size edit in the same second has a stat that matches the index exactly.
Upstream isomorphic-git has open issues for this: isomorphic-git/isomorphic-git#608 and isomorphic-git/isomorphic-git#1245.
Possible fix
- Apply the racy-git rule in the git client: hash an entry again when its whole-second mtime is equal to or later than the index file's mtime. Or,
- compare
mtimeNanosecondstoo, which the Workspace filesystem can fill from its millisecond times. This makes the window one millisecond, not one second, but it does not close it fully.
Activity
github-actions commentedon Nov 13, 2026
Thanks for the detailed report. We triaged it as a real bug: a same-size edit in the same second as the index stat is invisible to
statusandadd -A, so the commit silently keeps old content.Cause: every status/add path (
packages/computer/src/git/staging.tsaddAll,status.ts) relies on isomorphic-git's whole-second stat comparison, which has no racy-git rule.Recommended fix: make the racy case re-hash, either by wrapping the fs stat the adapter (
packages/computer/src/git/adapter.ts) hands to isomorphic-git or by patching the comparison, rather than comparing nanoseconds, which only narrows the window.Next: this touches the bundled library, so a maintainer decides the approach. They can add
gardener-fixto have it built.