Shell: git fs adapter’s mkdir drops error codes, breaking git init/clone on isomorphic-git ≥ 1.40.8 · Issue #2549 · cloudflare/agents · GitHub
Skip to content

Shell: git fs adapter’s mkdir drops error codes, breaking git init/clone on isomorphic-git ≥ 1.40.8 #2549

Open

Description

@jbreite

Describe the bug

createGit(fs).init() and clone() into a new directory fail on the shell filesystems (Workspace / WorkspaceFileSystem and InMemoryFs) when the installed isomorphic-git is 1.40.8 or newer:

Error: ENOENT: no such file or directory, mkdir '/repo/.git/hooks'

The git fs adapter's mkdir passes errors from the shell filesystem through unchanged. Those errors carry the code only in their message ("ENOENT: …", "EEXIST: …"), not as a code property. Since 1.40.8, isomorphic-git's mkdirp dispatches on err.code and throws anything that isn't EEXIST or ENOENT, so it can no longer create the missing parent or ignore an existing directory.

@cloudflare/shell declares "isomorphic-git": "^1.38.5", so a fresh install today resolves to an affected version (1.43.3).

To Reproduce

No Durable Object, network or provider needed:

import { InMemoryFs } from "@cloudflare/shell";
import { createGit } from "@cloudflare/shell/git";

await createGit(new InMemoryFs()).init({ dir: "/repo" });
// Error: ENOENT: no such file or directory, mkdir '/repo/.git/hooks'   (err.code === undefined)

The same happens with a Workspace-backed WorkspaceFileSystem, and with clone({ dir }) into a directory that doesn't exist yet. We hit it cloning Cloudflare Artifacts repositories into a Think workspace.

The filesystems' errors directly:

const fs = new InMemoryFs();
await fs.mkdir("/a");
await fs.mkdir("/a");     // Error: EEXIST: directory already exists, mkdir '/a'        code: undefined
await fs.mkdir("/x/y");   // Error: ENOENT: no such file or directory, mkdir '/x/y'     code: undefined

Expected behavior

init and clone create the repository, including every directory under .git, on any isomorphic-git version the package's range allows.

Screenshots

N/A — error output above.

Version:

@cloudflare/shell 0.4.3 (latest)
isomorphic-git 1.43.3 (fails), bisected below
runtime Cloudflare Workers + Durable Objects; also reproduces in Node with InMemoryFs

Additional context

Root cause

Pinned to main at 48d9c36f5f1ff18a91fbf0659b9a8e026d1cac43.

readFile, unlink, stat, lstat and readlink wrap their errors with fsError so isomorphic-git gets a code. mkdir doesn't:

async mkdir(path: string, mode?: number | { recursive?: boolean }): Promise<void> {
  const recursive = typeof mode === "object" ? mode.recursive : false;
  await fs.mkdir(path, { recursive });
},

isomorphic-git always calls mkdir(path) with no options, so this is a single-level mkdir, and its failures reach isomorphic-git without a code.

How it regressed

Bisected with the repro above against @cloudflare/shell@0.4.3:

isomorphic-git result
1.40.7 init "succeeds", but .git contains only HEAD and config
1.40.8 ENOENT … mkdir '/repo/.git/hooks'
1.43.3 same

Up to 1.40.7, FileSystem.mkdir silently swallowed an error that was neither EEXIST nor ENOENT:

if (err.code === 'EEXIST') return
if (_selfCall) throw err
if (err.code === 'ENOENT') { /* create parent, retry */ }
// anything else: falls through, error dropped

So on older versions the code-less errors were never surfaced. init skipped hooks, info, objects/* and refs/* without a word, and later writes happened to work because the adapter's writeFile creates parents recursively.

1.40.8 replaced this with a mkdirp helper that rethrows anything that isn't EEXIST/ENOENT:

if (err.code === 'EEXIST') return
if (err.code !== 'ENOENT') throw err

which turns the existing gap into a hard failure.

The repo's own tests don't catch it because pnpm-lock.yaml pins isomorphic-git@1.38.5.

Suggested fix

Route mkdir errors through fsError, and have fsError take the code from the message when the cause has none. Today it defaults every code-less error to ENOENT, which would report an existing directory to mkdirp as a missing one; it then walks up to the root and throws instead of returning:

function fsError(path: string, cause?: unknown): Error & { code: string } {
  if (cause instanceof Error && "code" in cause && typeof cause.code === "string") {
    return cause as Error & { code: string };
  }
  const message = cause instanceof Error ? cause.message : `ENOENT: ${path}`;
  const err = new Error(message) as Error & { code: string };
  err.code = /^(E[A-Z]+):/.exec(message)?.[1] ?? "ENOENT";
  return err;
}

async mkdir(path, mode) {
  const recursive = typeof mode === "object" ? mode.recursive : false;
  try {
    await fs.mkdir(path, { recursive });
  } catch (err) {
    throw fsError(path, err);
  }
},

That lets isomorphic-git's own mkdirp do its job on every version. Checked against 1.43.3 by adding codes this way around InMemoryFs.mkdir: init creates HEAD, config, hooks, info, objects and refs, and a second init succeeds. Setting code where InMemoryFs and Workspace throw would be the more thorough fix, and would help other Node-style consumers too.

A regression test fits next to the existing unlink cases in git-fs-adapter.test.ts: mkdir on an existing directory rejects with code: "EEXIST", mkdir under a missing parent rejects with code: "ENOENT", and createGit(new InMemoryFs()).init({ dir: "/repo" }) leaves /repo/.git/hooks and /repo/.git/refs/heads in place. Bumping the lockfile's isomorphic-git would let the Workspace-backed tests in git.test.ts cover it too.

We currently carry a one-line pnpm patch for @cloudflare/shell@0.4.3 that makes the adapter's mkdir always recursive (fs.mkdir(path, { recursive: true })). It works, but it masks the error codes rather than fixing them, so we'd rather drop it for an upstream fix.

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 working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions