DEV Community

Enjoy Kumawat
Enjoy Kumawat

Posted on

My Git Commit Failed With "ENOENT: git". Git Was Never the Problem.

I was committing a fix in a fork of vercel/eve — a small change to packages/eve/src/harness/tool-loop.ts for issue #412, nothing exotic. I staged the diff, ran git commit, and got this:

Error: spawnSync git ENOENT
    at ChildProcess.spawnSync (...)
Enter fullscreen mode Exit fullscreen mode

ENOENT for git. In a repo where I'd just run git status, git diff, git add about ten times in a row, all successfully, from the same shell. I checked which git — there it was, on PATH, exactly where it should be. Switched from git-bash to PowerShell in case it was a shell-specific PATH issue. Same error. Reinstalled nothing, because at that point I was fairly sure the problem wasn't actually "git is missing."

The instinct when a tool says a command isn't found is to go fix the command. That instinct cost me twenty minutes here, because git genuinely wasn't the thing that was missing.

Where the error actually came from

This repo uses simple-git-hooks with a pre-commit script, scripts/pre-commit-fmt.mjs. It runs formatters and linters before every commit, and internally it shells out to git itself (to get the list of staged files) via Node's spawnSync:

spawnSync("git", ["diff", "--staged", "--name-only"], { cwd: REPO_ROOT });
Enter fullscreen mode Exit fullscreen mode

spawnSync throws ENOENT in exactly two situations: the command isn't on PATH, or the cwd you gave it doesn't exist. Node's error object doesn't distinguish between the two — both come back as Error: spawnSync git ENOENT, with git sitting right there in the message, looking like the culprit. I'd been reading that message as "the OS couldn't find the executable git." What it actually meant was "the working directory I tried to run git from doesn't exist, so nothing could run there, full stop."

So the real question wasn't "where did git go" — it was "where did REPO_ROOT come from, and why would it be wrong."

The bug: a leading slash that Windows can't ignore

REPO_ROOT was computed like this:

const REPO_ROOT = path.resolve(new URL("..", import.meta.url).pathname);
Enter fullscreen mode Exit fullscreen mode

import.meta.url gives you the file URL of the current module — something like file:///D:/codes/eve/scripts/pre-commit-fmt.mjs on Windows. .pathname on a file:// URL strips the file:// prefix but keeps everything after it verbatim, including a leading slash before the drive letter: /D:/codes/eve/scripts/pre-commit-fmt.mjs. That leading / is meaningless on Windows — there's no root-level /D: directory — but path.resolve() doesn't know that. It treats the string as already-absolute and returns something like D:\D:\codes\eve\scripts, a path that mixes the drive letter with the also-drive-lettered fragment. It doesn't exist anywhere on disk.

Pass that as cwd to spawnSync, and Node can't even change into the directory to look for git, so it reports ENOENT on the command it was trying to run there. On macOS or Linux this line works fine, because POSIX paths don't have a drive letter to double up — file:///Users/me/eve/... has a .pathname that's already correct. That's exactly why this had shipped unnoticed: whoever wrote the hook tested it on a Unix machine, and it worked.

The correct way to turn a file:// URL into a filesystem path — on any OS — is Node's own fileURLToPath():

import { fileURLToPath } from "node:url";
const REPO_ROOT = fileURLToPath(new URL("..", import.meta.url));
Enter fullscreen mode Exit fullscreen mode

fileURLToPath knows about the Windows drive-letter case and strips the extra slash correctly. .pathname is a URL-spec accessor with no idea it's being asked to double as a filesystem path.

What I actually did about it

Rewriting a third-party pre-commit script wasn't in scope for a PR about an unrelated bug in the harness — a drive-by fix to someone else's build tooling, unrequested, in the same diff as a behavior fix, is exactly the kind of scope creep a reviewer has to untangle later. So I didn't patch pre-commit-fmt.mjs. Instead I ran the tools it would have run — oxfmt and oxlint — by hand against the changed files, confirmed clean output, then committed using the repo's own documented escape hatch:

SKIP_SIMPLE_GIT_HOOKS=1 git commit -m "..."
Enter fullscreen mode Exit fullscreen mode

I picked that over git commit --no-verify on purpose. --no-verify is generic — it skips every hook a repo has wired up, including ones that have nothing to do with the bug I'd found. SKIP_SIMPLE_GIT_HOOKS is specific to the one broken tool; it says "I know this particular hook is broken and I'm choosing to bypass it," not "skip whatever checks happen to be configured here." When you're working around a known bug in someone else's tooling, the narrowest available escape hatch is the one that doesn't also disable the checks that are working fine.

The transferable part

ENOENT from child_process/spawnSync in Node is genuinely ambiguous — "command not found" and "cwd not found" produce the identical error string, with the command name sitting right there making it look like the obvious suspect. If you hit ENOENT for a binary you can independently verify is on PATH, stop chasing PATH and go look at whatever cwd was passed to the spawn call instead. And if that cwd was derived from import.meta.url, check whether it went through .pathname or fileURLToPath().pathname is the one that quietly breaks on Windows, and it'll pass code review clean on a Mac every time.

Top comments (0)