DEV Community

ke jia
ke jia

Posted on

My Git CLI Was Silently Broken on Windows for 3 Months — A 2>/dev/null Bug That Taught Me the Hard Way

In May I shipped gitpulse — a tiny CLI that shows your git repo's analytics right in the terminal: commit stats, contributor graphs, file breakdowns, recent activity.

One command. Zero config. No dashboard. No login.

npx @wuchunjie/gitpulse
Enter fullscreen mode Exit fullscreen mode

Three months later, I ran it on my own Windows machine and got:

  ❌  Not a git repository.
Enter fullscreen mode Exit fullscreen mode

...inside a perfectly valid git repository. And the worst part: it had been failing on every Windows machine since day one. It just never told me.

The Bug

Here's the line that checked whether we're inside a git repo:

function run(cmd) {
  try {
    return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
  } catch {
    return "";
  }
}

const isRepo = run(`cd "${dir}" && git rev-parse --git-dir 2>/dev/null`);
if (!isRepo) {
  console.log("  ❌  Not a git repository.");
  process.exit(1);
}
Enter fullscreen mode Exit fullscreen mode

This is completely normal, boring code. git rev-parse --git-dir prints .git in a repo, nothing on stdout otherwise, and 2>/dev/null keeps the error message quiet. Works on Linux. Works on macOS. Works in my CI.

Then why did it die on Windows?

2>/dev/null Does Not Exist on Windows

On Linux and macOS, 2>/dev/null redirects stderr to the kernel's null device.

On Windows, Node's execSync doesn't run your command in bash — it runs it in cmd.exe. And cmd.exe interprets 2>/dev/null literally: "open the file at path /dev/null for writing."

That path doesn't exist. cmd.exe gives up with:

The system cannot find the path specified.
Enter fullscreen mode Exit fullscreen mode

The command exits non-zero, my catch block swallows it, isRepo becomes an empty string, and gitpulse cheerfully announces "Not a git repository" in a repo that's very much a repo.

Silent failure. Wrong error message. Zero stack trace. For three months.

The Debug Journey

Because I develop in git-bash, the first clue was confusing: git rev-parse --git-dir worked fine in my shell, so of course I ran the same command through Node to compare:

node -e "console.log(require('child_process').execSync('git rev-parse --git-dir', {encoding:'utf-8'}).trim())"
# → .git  ✅
Enter fullscreen mode Exit fullscreen mode

Works in Node. Fails in gitpulse. So I bisected the exact command string, adding pieces back one by one:

execSync('cd "." && git rev-parse --git-dir')          // ✅ works
execSync('cd "." && git rev-parse --git-dir 2>/dev/null')  // ❌ boom
Enter fullscreen mode Exit fullscreen mode

There it was. The redirect — the most innocent-looking six characters in the file — was the entire bug.

The Fix

One line: delete the redirect.

const isRepo = run(`cd "${dir}" && git rev-parse --git-dir`);
Enter fullscreen mode Exit fullscreen mode

Why is that safe? Because I'm already passing stdio: ["pipe", "pipe", "pipe"] — stderr is piped into Node's memory and never touches the terminal. There is nothing for 2>/dev/null to silence. The redirect wasn't protecting the user from noise; it was just a habit I'd copied from a shell script.

That's the whole fix. No platform detection. No 2>nul on Windows. Just: don't put POSIX shell syntax inside a string you hand to child_process on Windows.

Rules I'm Writing Down Now

  1. execSync on Windows is cmd.exe, not bash. No 2>/dev/null, no && chains with POSIX paths, no $VAR expansion.
  2. Prefer Node options over shell tricks. stdio: "ignore" or piped streams beat every shell redirect.
  3. git commands print errors to stderr and exit codes — you usually don't need to suppress anything if you handle the exit code properly.
  4. Test on the OS your users are on. I write "cross-platform" in my README and then test on the one machine in my head. gitpulse users on Windows were getting a fake error for 90 days and I never noticed.

What gitpulse Actually Shows

The apology tour is over — here's the tool. Run it in any repo:

  📊  GITPULSE

  📝  Total commits: 2
  👤  Contributors:  1
  📅  Active days:   1
  📂  Files touched:  2

  👥  Top Contributors
    T                         ███████████████ 2

  📂  File Type Breakdown
    .txt        ███████████████ 2

  🔥  Recent Activity
    2026-08-30  ██████████████████████████████ 2
Enter fullscreen mode Exit fullscreen mode

Point it at a big team repo and you get your bus-factor graph, the file-type mix (how much of your history is .ts vs .md vs mystery binary files), and a per-day activity strip — all rendered with plain Unicode blocks, no dependencies, no server, nothing leaving your machine.

It's the "wait, who actually maintains this repo?" answer, without opening a browser.

npx @wuchunjie/gitpulse            # current repo
npx @wuchunjie/gitpulse /path/to/repo
Enter fullscreen mode Exit fullscreen mode

If You're Shipping CLIs

Your users are on Windows. Not "some of your users." The ones who will install it, run it, get a confusing error, and quietly never come back.

Audit your execSync strings. If any of them contain 2>/dev/null, >/dev/null, or a bash-ism of any flavor, they are lying to your Windows users right now.

And if you ever build a tool that looks at your git history the way you'd wish GitHub Insights did — but faster, offline, and in your terminal — gitpulse is live on npm. npx @wuchunjie/gitpulse and tell me what your repo looks like. I read every comment.

Top comments (0)