Skip to content

git misses a same-size edit made in the same second as the staged stat ("racy git") #325

Open

Description

@matthaigh27

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 status shows the file as unchanged.
  • git add -A stages 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/computer 0.11.2 (also seen on 0.10.0)
  • @platformatic/vfs 0.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 mtimeNanoseconds too, 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

  1. github-actions commented on Nov 13, 2026

    @github-actions
    Contributor

    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 status and add -A, so the commit silently keeps old content.

    Cause: every status/add path (packages/computer/src/git/staging.ts addAll, 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-fix to have it built.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingpriority: highHigh impact: fix soon

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions