You know that feeling when your hook works perfectly on macOS, then you test it on Windows and everything explodes? Yeah, we’ve all been there. The thing is, .mjs hooks on Windows aren’t actually harder than on Unix systems—they’re just different. And once you understand the pain points, you can write hooks that work seamlessly across platforms.
This guide walks through the real-world issues you’ll hit when building ES module hooks on Windows, and gives you battle-tested recipes to solve them. We’re not going to gloss over path handling or line endings. We’re going to treat them as first-class citizens.
Why Windows Hooks Are Different
Windows isn’t Unix. That might seem obvious, but it matters for hook development in specific ways:
Path separators use backslashes instead of forward slashes. Node’s internal APIs handle this, but when you’re parsing file paths or constructing commands, you can’t assume /.
Line endings default to CRLF (\r\n) instead of LF (\n). Text files you read might contain different line endings than you expect.
Process spawning works differently. On Windows, you can’t directly execute shell scripts like ./script.sh. You need an explicit interpreter or .cmd file.
Encoding defaults might surprise you. Windows often prefers UTF-16LE or code pages; Node defaults to UTF-8 but doesn’t force it.
These differences compound when you’re writing hooks that need to be portable. Let’s build solutions for each.
Recipe 1: Windows-Safe Path Normalization
The foundational problem: you’ll receive paths in different formats. Your hook might get C:\Users\you\project\file.js or C:/Users/you/project/file.js depending on where it came from. You need to normalize them.
// path-normalizer.mjs
// Convert file:// URLs and mixed-case paths to normalized absolute paths
export function normalizeWindowsPath(filePath) {
// If it's a URL, convert to file path
if (filePath.startsWith("file://")) {
filePath = fileURLToPath(filePath);
}
// Use Node's path.normalize to handle mixed separators
const normalized = path.normalize(filePath);
// On Windows, convert to absolute path if relative
if (process.platform === "win32" && !path.isAbsolute(normalized)) {
return path.resolve(process.cwd(), normalized);
}
return normalized;
}
// Test
console.log(normalizeWindowsPath("C:/Users/dev/project/test.js"));
// Output: C:\Users\dev\project\test.js
console.log(normalizeWindowsPath("./src/index.js"));
// Output: C:\absolute\path\src\index.js (context-dependent)
Why this matters: Your hook might receive file://C:/Users/... from the module loader. The fileURLToPath function handles the conversion. Then path.normalize makes all separators consistent for your platform. On Windows, this always uses backslashes.
The second part handles relative paths. If your hook receives ./src/index.js and you need to compare it against resolved paths, you need the absolute version.
Recipe 2: Safe Child Process Spawning
You need to run Node or other tools from your hook. On Windows, spawn options work differently:
// child-process-safe.mjs
export function spawnCommandSafe(command, args = [], options = {}) {
// Key: On Windows, most commands need the .cmd wrapper
const isWindows = process.platform === "win32";
const cmdToRun = isWindows ? `${command}.cmd` : command;
const defaultOptions = {
// Inherit stdio to see errors in real time
stdio: "inherit",
// Windows defaults to UTF-8 encoding
encoding: "utf8",
// Don't timeout (adjust per your needs)
timeout: 30000,
// Windows needs shell: true for many commands
shell: isWindows,
};
const merged = { ...defaultOptions, ...options };
try {
const result = spawnSync(cmdToRun, args, merged);
if (result.error) {
throw new Error(`Failed to spawn ${cmdToRun}: ${result.error.message}`);
}
if (result.status !== 0) {
console.error(`Command ${cmdToRun} exited with code ${result.status}`);
return { success: false, code: result.status, output: result.stdout };
}
return { success: true, code: 0, output: result.stdout };
} catch (error) {
console.error(`Error spawning ${cmdToRun}:`, error.message);
return { success: false, error: error.message };
}
}
// Usage example
const result = spawnCommandSafe("node", ["--version"]);
console.log(result);
// Output: { success: true, code: 0, output: 'v20.10.0\n' }
Why this works: On Windows, executables in your PATH usually need the .cmd wrapper (like npm.cmd). The shell: true option tells Windows to process the command through cmd.exe, which resolves environment variables and PATH lookups. The stdio: ‘inherit’ option pipes output directly to your terminal, so you see errors immediately instead of buffering them.
The try-catch wraps common failure modes. If the command doesn’t exist, spawnSync throws. If it exits with non-zero status, you can log that too.
Recipe 3: Line Ending Normalization for Text Files
Your hook might read configuration files or source code. On Windows, these might have CRLF line endings. If you’re doing line-by-line processing, you need to handle this:
// line-endings.mjs
export function readLinesNormalized(filePath) {
// Read as UTF-8 (standard for modern Node)
const content = fs.readFileSync(filePath, "utf8");
// Normalize all line endings to \n
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Split and filter empty lines
return normalized.split("\n").filter((line) => line.length > 0);
}
export function writeLinesNormalized(
filePath,
lines,
useWindowsEndings = true,
) {
// Join lines with LF
let content = lines.join("\n");
// If writing on Windows and you want native line endings, convert
if (useWindowsEndings && process.platform === "win32") {
content = content.replace(/\n/g, "\r\n");
}
fs.writeFileSync(filePath, content, "utf8");
}
// Test example
const testFile = "./test-config.txt";
fs.writeFileSync(testFile, "line1\r\nline2\r\nline3", "utf8");
const lines = readLinesNormalized(testFile);
console.log(lines);
// Output: ['line1', 'line2', 'line3']
writeLinesNormalized("./output.txt", lines, true);
// Writes with Windows CRLF line endings
The hidden layer: Why normalize to LF first, then maybe convert back? Because your code logic should work with consistent line endings internally. Only at the boundary (reading and writing) do you handle platform differences. This keeps the middle of your hook clean.
The useWindowsEndings parameter lets you control the output format. Some tools prefer LF even on Windows (Git, most modern linters). Others expect CRLF. You decide.
Recipe 4: Encoding-Safe File Reading
Windows systems might serve you files in different encodings. Being defensive pays off:
// encoding-safe.mjs
export async function readFileSafe(filePath) {
try {
// First try UTF-8 (default)
const content = fs.readFileSync(filePath, "utf8");
// Validate it doesn't have replacement characters
if (!content.includes("\uFFFD")) {
return { success: true, content, encoding: "utf8" };
}
} catch (err) {
// UTF-8 parsing failed, try detection
}
// Detect encoding if UTF-8 fails
const buffer = fs.readFileSync(filePath);
const detectedEncoding = detect(buffer) || "utf8";
try {
const content = buffer.toString(detectedEncoding);
return { success: true, content, encoding: detectedEncoding };
} catch (err) {
return {
success: false,
error: `Cannot decode ${filePath}`,
encoding: null,
};
}
}
// Usage
const result = await readFileSafe("./legacy-config.txt");
if (result.success) {
console.log(`Read ${result.encoding}:`, result.content.slice(0, 50));
} else {
console.error(result.error);
}
Why this approach: Most of the time, UTF-8 works. But on older Windows systems or when integrating with legacy tools, you might hit a file in Windows-1252 or another encoding. The chardet library detects encoding heuristically. This lets you handle both cases.
The check for the replacement character (\uFFFD) is clever: if UTF-8 parsing succeeds but produces that character, it means the encoding was wrong and Node substituted a placeholder. That’s your signal to try detection.
Recipe 5: A Complete Pre-Commit Hook Example
Now let’s combine everything into a realistic hook. This one runs linters before commit:
// pre-commit.mjs
export async function runPreCommitHook(options = {}) {
const {
linters = ["eslint", "prettier"],
extensions = [".js", ".mjs", ".ts"],
failFast = false,
} = options;
console.log("[pre-commit] Starting hook...");
// Get staged files from git
const result = spawnCommandSafe("git", ["diff", "--cached", "--name-only"]);
if (!result.success) {
console.error("[pre-commit] Failed to get staged files");
return false;
}
const stagedFiles = result.output
.split("\n")
.filter((f) => f.trim().length > 0)
.map((f) => normalizeWindowsPath(f))
.filter((f) => extensions.some((ext) => f.endsWith(ext)));
if (stagedFiles.length === 0) {
console.log("[pre-commit] No matching files to lint");
return true;
}
console.log(`[pre-commit] Linting ${stagedFiles.length} files...`);
let allPassed = true;
for (const linter of linters) {
console.log(`\n[pre-commit] Running ${linter}...`);
const lintResult = spawnCommandSafe(linter, stagedFiles);
if (!lintResult.success) {
console.error(`[pre-commit] ${linter} failed`);
allPassed = false;
if (failFast) {
return false;
}
} else {
console.log(`[pre-commit] ${linter} passed`);
}
}
if (allPassed) {
console.log("[pre-commit] ✓ All checks passed");
} else {
console.error("[pre-commit] ✗ Some checks failed");
}
return allPassed;
}
// Entry point for hook system
if (import.meta.url === `file://${process.argv[1]}`) {
const success = await runPreCommitHook();
process.exit(success ? 0 : 1);
}
What’s happening here: The hook gets staged files via git diff --cached --name-only. On Windows, those paths come back with forward slashes or backslashes—we normalize them. Then we filter to files matching our extensions. Finally, we spawn each linter with the file list.
The failFast option is useful: set it true to stop at the first failure, or false to run all linters and report everything at once.
Recipe 6: Handling .cmd and Batch Files
Sometimes your hook needs to call batch scripts or Windows-specific tools. You can’t directly execute .bat or .cmd files the way you’d execute shell scripts on Unix:
// batch-executor.mjs
export function executeBatchFile(batchPath, args = []) {
// Windows batch files must be executed through cmd.exe
const cmdArgs = ["/c", batchPath, ...args];
const result = spawnSync("cmd", cmdArgs, {
stdio: "inherit",
encoding: "utf8",
shell: true, // cmd.exe is the shell
});
return {
success: result.status === 0,
code: result.status,
error: result.error ? result.error.message : null,
};
}
// Usage
const batchResult = executeBatchFile("build.bat", ["--release"]);
if (batchResult.success) {
console.log("Build completed");
} else {
console.error(`Build failed with code ${batchResult.code}`);
}
Why /c: The /c flag tells cmd.exe to execute the command and exit. Without it, cmd would start an interactive session.
Recipe 7: Environment Variables on Windows
Windows environment variable names are case-insensitive. This creates subtle bugs:
// env-safe.mjs
export function getEnvVar(name) {
// Node's process.env is case-sensitive, even on Windows
// So we need to search case-insensitively
if (process.platform === "win32") {
const lowerName = name.toLowerCase();
for (const [key, value] of Object.entries(process.env)) {
if (key.toLowerCase() === lowerName) {
return value;
}
}
return undefined;
}
// On Unix, just do direct lookup
return process.env[name];
}
// Usage
const path = getEnvVar("PATH");
const Path = getEnvVar("Path");
const PaTh = getEnvVar("PaTh");
console.log(path === Path && Path === PaTh); // true on Windows, may differ on Unix
The gotcha: If your hook checks process.env.PATH on Windows and someone set Path instead, you’ll get undefined. This case-insensitive lookup handles that.
Recipe 8: Temporary Files Safely
Creating temp files on Windows requires care. The temp directory location differs:
// temp-files.mjs
export function createTempFile(prefix = "temp-") {
// Use os.tmpdir() for platform-aware temp location
const tmpDir = os.tmpdir();
// Generate unique name
const timestamp = Date.now();
const random = Math.random().toString(36).slice(2);
const filename = `${prefix}${timestamp}-${random}`;
const tempPath = path.join(tmpDir, filename);
// Create and immediately return path (don't keep it open)
fs.writeFileSync(tempPath, "");
return tempPath;
}
export function cleanupTempFile(filePath) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (err) {
console.warn(`Could not delete temp file ${filePath}:`, err.message);
}
}
// Usage
const tempFile = createTempFile("hook-");
try {
// Use tempFile
fs.writeFileSync(tempFile, "temporary data");
console.log("Temp file:", tempFile);
} finally {
cleanupTempFile(tempFile);
}
Why os.tmpdir(): On Windows, this returns something like C:\Users\you\AppData\Local\Temp. On macOS, /var/folders/.... Node figures it out for you. Never hardcode /tmp.
Recipe 9: Timeout-Safe Long-Running Hooks
Hooks sometimes get stuck. Windows process termination is more aggressive than Unix SIGTERM:
// timeout-safe.mjs
export function runWithTimeout(command, args, timeoutMs = 30000) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: "inherit",
shell: process.platform === "win32",
});
const timer = setTimeout(() => {
console.error(`[timeout] Killing ${command} after ${timeoutMs}ms`);
if (process.platform === "win32") {
// Windows requires TASKKILL to force-terminate
spawn("taskkill", ["/pid", child.pid.toString(), "/f"]);
} else {
// Unix: send SIGKILL
child.kill("SIGKILL");
}
reject(new Error(`Command ${command} timed out after ${timeoutMs}ms`));
}, timeoutMs);
child.on("exit", (code) => {
clearTimeout(timer);
if (code === 0) {
resolve({ success: true, code });
} else {
reject(new Error(`Command exited with code ${code}`));
}
});
child.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
});
}
// Usage
try {
await runWithTimeout("npm", ["run", "lint"], 60000);
console.log("Linting completed within timeout");
} catch (err) {
console.error("Linting failed or timed out:", err.message);
}
The trick: On Windows, child.kill() without arguments does nothing. You need TASKKILL /f (force kill). On Unix, SIGKILL is more reliable. The promise-based wrapper lets you use async/await.
Recipe 10: Debugging Hooks with Verbose Logging
When things break, you need visibility. Add structured logging:
// debug-logging.mjs
export class HookLogger {
constructor(name, verbose = false) {
this.name = name;
this.verbose = verbose || process.env.HOOK_DEBUG === "1";
}
info(message, data = null) {
console.log(`[${this.name}] ℹ ${message}`);
if (data && this.verbose) {
console.log(JSON.stringify(data, null, 2));
}
}
error(message, error = null) {
console.error(`[${this.name}] ✗ ${message}`);
if (error) {
if (this.verbose) {
console.error(error.stack || error);
} else {
console.error(` ${error.message}`);
}
}
}
debug(message, data = null) {
if (this.verbose) {
console.log(`[${this.name}] 🔧 ${message}`);
if (data) console.log(JSON.stringify(data, null, 2));
}
}
}
// Usage
const logger = new HookLogger("pre-commit");
logger.info("Hook started");
logger.debug("Platform detected", { platform: process.platform });
logger.error("Something went wrong", new Error("Test error"));
// Enable verbose mode:
// HOOK_DEBUG=1 node hook.mjs
Why this design: The verbose flag defaults to false, keeping normal runs clean. Set HOOK_DEBUG=1 before running and you get full details. This is crucial for debugging Windows-specific issues without cluttering the normal output.
Common Windows Hook Pitfalls
Pitfall 1: Assuming forward slashes in paths. Always use path.normalize() or path.join() to construct paths. Never hardcode /.
Pitfall 2: Not handling exit codes. On Windows, spawned processes sometimes exit with codes that aren’t 0/non-zero. Always check the exact code and handle unexpected values gracefully.
Pitfall 3: Forgetting .cmd extensions. If your hook calls an npm script or a local tool, remember that on Windows it might need .cmd. Use spawnSync with shell: true to let Windows resolve it.
Pitfall 4: Reading files without encoding. Always specify encoding explicitly as utf8. Don’t assume the system default.
Pitfall 5: Not cleaning up temp files. Hooks run repeatedly. If each one creates temp files without cleanup, you’ll accumulate junk. Use try/finally to guarantee cleanup.
Putting It All Together
Here’s a complete, production-grade hook that uses all these recipes:
// post-merge-hook.mjs
const logger = new HookLogger("post-merge");
export async function postMergeHook() {
logger.info("Running post-merge hook");
try {
// Check if package.json was modified
const diffResult = spawnCommandSafe("git", [
"diff",
"HEAD@{1}",
"--name-only",
]);
if (!diffResult.success) {
logger.error("Failed to get diff");
return false;
}
const changedFiles = diffResult.output
.split("\n")
.map(normalizeWindowsPath);
const packageJsonChanged = changedFiles.some((f) =>
f.endsWith("package.json"),
);
if (!packageJsonChanged) {
logger.info("No dependency changes detected");
return true;
}
logger.info("Dependencies changed, running npm install");
await runWithTimeout("npm", ["install"], 120000);
logger.info("Dependencies installed successfully");
return true;
} catch (error) {
logger.error("Post-merge hook failed", error);
return false;
}
}
// Entry point
if (import.meta.url === `file://${process.argv[1]}`) {
const success = await postMergeHook();
process.exit(success ? 0 : 1);
}
export default postMergeHook;
This hook handles the most common post-merge case: reinstalling dependencies if package.json changed. It uses path normalization, safe spawning, timeout protection, and structured logging. It works identically on Windows, macOS, and Linux.
Your Next Steps
Start with Recipe 1 (path normalization). It’s the foundation everything else builds on. Test it on Windows with mixed path formats. Once that works, layer in child process spawning (Recipe 2). Finally, add logging and run the whole thing through a real hook.
The recipes here are battle-tested. They’re not fancy. They’re practical solutions to problems you will actually hit on Windows. Use them as templates for your own hooks, adapt them to your project’s specifics, and your hooks will be robust across platforms.
Windows may be different from Unix, but that’s not a handicap—it’s just knowledge. Master these recipes and you’ll write hooks that work everywhere.
The Hidden Layer: Why These Patterns Matter
Behind every recipe in this guide sits a deeper principle: cross-platform development isn’t about writing code twice—it’s about understanding your platform’s constraints and designing around them. Most developers approach Windows compatibility reactively. Something breaks on Windows, they add a special case, and call it a day. Then something else breaks. Then something else. After a dozen patches, the code becomes unmaintainable because it’s layered with platform-specific workarounds that nobody fully understands.
The right approach is proactive. Before you write a single line, think about the platform differences. Path handling. Line endings. Environment variables. Process spawning. These aren’t edge cases—they’re first-class design concerns. When you treat them that way, your code becomes naturally cross-platform. The special cases become rare exceptions, not the default.
Notice how Recipe 1 (path normalization) comes first in this guide. That’s not accidental. Path handling is the foundation. Get that wrong, and everything upstream breaks. Get it right, and the rest of the recipes stack cleanly on top. Each recipe builds on that foundation: normalized paths flow through child process spawning, which flows through git operations, which flows through logging. There’s an intentional architecture here.
This is what separates hooks that barely work from hooks that work reliably. It’s the difference between “Windows is broken” and “Windows is different, and I’ve designed for both.”
Real-World Failure Modes and How These Recipes Prevent Them
Let’s talk about what actually happens when you ignore these patterns. You’re going to deploy a hook that works great on your Mac. It goes into production. Windows developers start hitting errors. Some intermittent, some deterministic. You’re scrambling to understand why something so simple is so broken.
The first failure mode comes from path assumptions. You’ve got a hook that reads a configuration file at ./config.json. On Mac, that’s fine. On Windows, you’ve got mixed separators coming from different sources, and suddenly your path comparison fails. The hook decides the config file doesn’t exist when it actually does. Silent failures are the worst—they leave users confused about why their git operations aren’t working.
The second failure mode is process spawning. You’ve written a hook that calls a linter on staged files. Works great locally. But when you run it on Windows in the CI pipeline, the linter command fails because you’re not using the .cmd wrapper. Now your CI is broken for Windows developers but nobody knows why. The error message is cryptic because the child process wasn’t actually spawned.
The third failure mode is encoding issues. You’re reading a git configuration file that some legacy tool created in Windows-1252 encoding. Your hook assumes UTF-8, tries to parse it, and crashes. On some Windows machines, this never happens. On others, it’s frequent. Debugging becomes a nightmare because the issue is nondeterministic.
Each recipe in this guide is a defense against one of these failure modes. Path normalization prevents comparison failures. Child process spawning with shell: true ensures commands are found. Encoding-safe reading handles legacy file encodings. These aren’t theoretical—they’re hardened by real-world deployment pain.
Integration Pattern: Hook Composition
If you’re writing multiple hooks for your repository, there’s a pattern that makes them maintainable: extract common utilities into a shared library.
Create a hooks/utils/ directory and move your recipes there. Then your individual hooks become thin wrappers that import these utilities. Your pre-commit hook imports spawnCommandSafe and normalizeWindowsPath. Your post-merge hook does the same. Now when you find a bug in one of these utilities, you fix it once and all hooks benefit immediately.
This is especially important for error handling and logging. All your hooks should log to the same logger instance with consistent formatting. All your hooks should handle errors the same way. By centralizing these patterns in a utils library, you avoid duplicating that logic across ten different hooks. You also avoid the situation where one hook silently fails while another doesn’t.
Performance Considerations for Hooks
Windows hooks run in the context of a git operation, which means they’re on the critical path of your development workflow. If your hook is slow, developers notice immediately and start disabling it. Performance matters.
The recipes here are reasonably fast, but large project scanning can get slow. If you’re linting hundreds of files, that’s going to take time. The solution is to limit scope. Use git diff --cached to get only staged files, not the entire repository. If you need to check a whole folder, cache the results so you only re-lint what actually changed.
For post-merge hooks that run npm install, you can optimize by only running when package.json or package-lock.json actually changed. Get this right and the hook runs in milliseconds on most merges. Get it wrong and developers wait 30 seconds for the hook to detect that nothing changed. That’s the difference between a tool people tolerate and a tool people love.
Monitoring and Observability
Once your hooks are in production, you need to know when they’re failing. The HookLogger pattern from Recipe 10 is the foundation, but you should go further.
Consider exporting hook execution metrics: how often each hook runs, success rate, execution time. If you’re using CI/CD, you can push these metrics to a monitoring system. Then you can alert on abnormal patterns. If your pre-commit hook suddenly starts taking 10x longer, you want to know immediately. If your post-merge hook starts failing on 50% of merges, that’s actionable data.
For developers working on Windows, set HOOK_DEBUG=1 in your environment to get verbose output. Include those logs in bug reports. The structured logging format makes debugging much faster than scattered console output.
Deployment Strategies: Rolling Out Hooks to Your Team
Here’s where theory meets reality: getting Windows users to actually run your hooks. You’ve written beautiful, cross-platform .mjs hooks. Now you need to deploy them and have your entire team—including skeptical Windows developers—use them successfully.
The deployment challenge is different on Windows. On macOS and Linux, developers are accustomed to installing tools. Windows developers often are not. They expect things to work out of the box. If your hook fails on Windows, the developer’s first assumption is “this tool is broken,” not “I need to fix my environment.”
The solution is a setup script that verifies prerequisites and guides developers through fixes. Create a hooks/setup.mjs that checks: Is Node.js installed? Is npm installed? Are the necessary executables in PATH? Are Git hooks installed? For each check that fails, provide a clear remediation step.
// hooks/setup.mjs
export function verifySetup() {
const checks = [
{
name: "Node.js",
test: () => {
try {
execSync("node --version", { stdio: "pipe" });
return true;
} catch {
return false;
}
},
fix: "Download from https://nodejs.org (v18 or higher)",
},
{
name: "npm",
test: () => {
try {
execSync("npm --version", { stdio: "pipe" });
return true;
} catch {
return false;
}
},
fix: "npm is bundled with Node.js. Reinstall Node.js.",
},
{
name: "Git",
test: () => {
try {
execSync("git --version", { stdio: "pipe" });
return true;
} catch {
return false;
}
},
fix: "Download from https://git-scm.com",
},
{
name: "Husky hooks",
test: () => existsSync(".husky/_"),
fix: "Run: npm run prepare",
},
];
let allPassed = true;
console.log("\n═══════════════════════════════════════════");
console.log("Verifying development environment...");
console.log("═══════════════════════════════════════════\n");
checks.forEach((check) => {
const passed = check.test();
const status = passed ? "✓" : "✗";
console.log(`${status} ${check.name}`);
if (!passed) {
console.log(` → Fix: ${check.fix}`);
allPassed = false;
}
});
console.log("\n═══════════════════════════════════════════\n");
if (!allPassed) {
console.error("Setup incomplete. Please fix the issues above.");
process.exit(1);
}
console.log("✓ All checks passed. You're ready to go!");
process.exit(0);
}
if (import.meta.url === `file://${process.argv[1]}`) {
verifySetup();
}
Call this from your setup process:
{
"scripts": {
"prepare": "node hooks/setup.mjs && husky install"
}
}
When a developer clones and runs npm install, the setup script runs. If something’s missing, they get clear guidance. No guessing. No debugging. Just “here’s what’s wrong, here’s how to fix it.”
Handling Common Windows Frustrations
Windows developers hit certain issues repeatedly. Here are targeted solutions:
“npm scripts don’t work”: Windows PATH resolution is different. Use cross-env to set environment variables consistently, and use node to run JavaScript instead of relying on shell scripts.
“File locks prevent deletion”: VS Code or another editor might have files open. Use graceful deletion with retry logic:
export function deleteWithRetry(filePath, maxRetries = 3) {
let lastError;
for (let i = 0; i < maxRetries; i++) {
try {
fs.unlinkSync(filePath);
return true;
} catch (err) {
lastError = err;
// Wait 100ms before retrying
const start = Date.now();
while (Date.now() - start < 100) {} // Busy-wait
}
}
console.warn(`Could not delete ${filePath}: ${lastError.message}`);
return false;
}
“Antivirus flags the tool”: Windows Defender sometimes flags unfamiliar executables. Recommend developers add your project folder to the exclusion list. The issue isn’t your code—it’s Windows being defensive.
Monitoring Hook Usage and Health
Once hooks are deployed, monitor them. You need to know if they’re working, if they’re causing problems, if developers are skipping them. Add simple telemetry:
// hooks/lib/telemetry.mjs
const telemetryFile = path.join(os.homedir(), ".hook-telemetry.json");
export function recordEvent(hookName, status, duration) {
let data = [];
if (fs.existsSync(telemetryFile)) {
data = JSON.parse(fs.readFileSync(telemetryFile, "utf8"));
}
data.push({
hook: hookName,
status,
duration,
timestamp: new Date().toISOString(),
platform: process.platform,
});
// Keep only last 100 events
if (data.length > 100) {
data = data.slice(-100);
}
fs.writeFileSync(telemetryFile, JSON.stringify(data, null, 2), "utf8");
}
export function getMetrics() {
if (!fs.existsSync(telemetryFile)) return null;
return JSON.parse(fs.readFileSync(telemetryFile, "utf8"));
}
This doesn’t phone home (privacy-respecting). It just logs locally so you can ask “how often do developers run hooks?” and “on which platforms are we seeing failures?”
Community and Long-Term Maintenance
If your hooks are shared across a team or open-sourced, community feedback is invaluable. Encourage Windows users to report issues. When they do, take them seriously. A Windows user reporting a path issue isn’t an edge case—they’re telling you your code has a bug that affects a significant portion of your user base.
Maintain a changelog that explicitly callouts platform-specific fixes. “Fixed path handling on Windows,” “Improved shell compatibility on Windows,” etc. This signals to Windows users that you care about their experience.
Advanced Pattern: Hook Chains and Composition
Once you have solid individual hooks, you’ll eventually want them to work together. This is where things get interesting and also where Windows complexity increases. When hooks call other hooks or when one hook triggers another, platform-specific issues compound. Managing these compositions requires careful attention to state, error handling, and cross-platform consistency—more so on Windows than on Unix because process spawning is more expensive and subprocess communication is more fragile.
Designing for Hook Composition
Hook composition means one hook’s output becomes another hook’s input. Your pre-commit hook runs eslint. If it passes, it triggers a complexity-analyzer hook. If that passes, it triggers a security-scanner hook. Each hook in the chain assumes the previous one completed successfully and left the system in a known state.
The challenge on Windows is that each hook boundary is a subprocess spawn. Spawning processes is more expensive on Windows than on Unix. If your composition chain spawns ten different processes, Windows might be slower than Unix, but it’ll still work—provided you handle each subprocess correctly with the recipes in this guide.
The key insight for composition: design assuming the chain might fail partway through. If hook three fails, can hook four still run safely? Or does your chain need all-or-nothing atomicity? Different projects have different requirements. Some teams want strict sequential validation where any failure blocks the commit. Others prefer progressive validation where you run everything and report all issues at the end. Know which philosophy your project needs, then design your composition pattern accordingly.
Using Environment Variables for Inter-Hook Communication
Hooks in a chain need to communicate. Hook one finds potential issues. Hook two needs to know about them. On Unix, you might use pipes. On Windows, that’s fragile. A better approach is environment variables.
// hooks/lib/state.mjs
const stateFile = path.join(os.tmpdir(), `hook-state-${process.pid}.json`);
export function setState(key, value) {
let state = {};
if (fs.existsSync(stateFile)) {
state = JSON.parse(fs.readFileSync(stateFile, "utf8"));
}
state[key] = value;
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), "utf8");
}
export function getState(key) {
if (!fs.existsSync(stateFile)) return null;
const state = JSON.parse(fs.readFileSync(stateFile, "utf8"));
return state[key];
}
export function clearState() {
if (fs.existsSync(stateFile)) {
fs.unlinkSync(stateFile);
}
}
Each hook writes its results to a temp file. The next hook reads it. This is platform-agnostic and reliable. The temp file cleanup happens naturally because you’re using os.tmpdir() and file-based storage.
Testing Strategies for Cross-Platform Hooks
Your hooks need to work everywhere across all platforms. Testing is absolutely non-negotiable for production hooks. But testing cross-platform behavior is significantly harder than testing on a single platform because you might not have direct access to Windows while developing on macOS or Linux.
Simulated Windows Path Testing
You can test Windows path handling on any platform by using mocked file paths:
// test/path-normalization.test.js
// Test Windows paths without actually running on Windows
const tests = [
{
input: "C:/Users/dev/project/file.js",
expected: "C:\\Users\\dev\\project\\file.js", // Windows style
},
{
input: "file://C:/Users/dev/project/file.js",
expected: "C:\\Users\\dev\\project\\file.js",
},
{
input: "./relative/path.js",
shouldBePlain: true, // Should be resolved based on platform
},
];
// Run tests with platform override
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
function testOnPlatform(platformName, testFn) {
Object.defineProperty(process, "platform", {
value: platformName,
configurable: true,
});
testFn();
Object.defineProperty(process, "platform", originalPlatform);
}
testOnPlatform("win32", () => {
tests.forEach(({ input, expected }) => {
const result = normalizeWindowsPath(input);
assert.strictEqual(
result,
expected,
`Failed for ${input}: got ${result}`,
);
});
});
console.log("✓ Windows path normalization tests passed");
By temporarily overriding process.platform, you can test Windows-specific behavior from any platform. This catches many issues before you deploy.
CI/CD Validation for Hooks
Your CI pipeline should validate hooks on all platforms. Use a matrix strategy:
# .github/workflows/hook-validation.yml
name: Hook Validation
on: [push, pull_request]
jobs:
test-hooks:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
- run: npm install
- run: npm run test:hooks
- run: npm run lint:hooks
Now every PR runs hook tests on Windows, macOS, and Linux with multiple Node versions. You catch platform-specific issues before they reach production.
Performance Optimization for Hooks
Hooks run on every git operation. If they’re slow, developers will disable them or work around them. Performance matters.
Benchmarking Your Hooks
Start with baseline measurements:
// hooks/lib/benchmark.mjs
export async function benchmark(name, fn) {
const start = performance.now();
const result = await fn();
const duration = performance.now() - start;
console.log(`[${name}] ${duration.toFixed(2)}ms`);
return result;
}
// Usage in your hook:
await benchmark("git diff", () => getRecentGitHistory(projectRoot));
Hook execution time should be sub-second for the entire chain. If any single hook takes more than 100ms, users start noticing. If the entire pre-commit chain takes more than 500ms, developers start getting annoyed.
Caching and Optimization Patterns
- Cache git operations: Don’t run
git diffmultiple times. Store the result. - Early exit: If a pre-check fails, skip expensive validation steps.
- Parallel hooks: Run independent checks in parallel with
Promise.all(). - Async everywhere: Never block. Always use async/await.
// Optimized pre-commit hook
async function runPreCommitHook() {
// Get staged files once, reuse it
const stagedFiles = await getStagedFiles();
// Exit early if nothing to check
if (stagedFiles.length === 0) return true;
// Run independent checks in parallel
const results = await Promise.all([
runLinters(stagedFiles),
checkTypeScript(stagedFiles),
validateImports(stagedFiles),
]);
return results.every((r) => r.success);
}
Monitoring and Observability
Once your hooks are in production across your team, you need to know if they’re working, failing, or causing issues.
Hook Metrics Collection
Collect metrics without being invasive:
// hooks/lib/metrics.mjs
const metricsFile = path.join(os.homedir(), ".hook-metrics.json");
export function recordMetric(hookName, status, durationMs) {
let metrics = [];
if (fs.existsSync(metricsFile)) {
try {
metrics = JSON.parse(fs.readFileSync(metricsFile, "utf8"));
} catch {
metrics = [];
}
}
metrics.push({
hook: hookName,
status,
duration: durationMs,
timestamp: new Date().toISOString(),
platform: process.platform,
});
// Keep only last 1000 metrics
if (metrics.length > 1000) {
metrics = metrics.slice(-1000);
}
fs.writeFileSync(metricsFile, JSON.stringify(metrics, null, 2), "utf8");
}
export function getMetrics() {
if (!fs.existsSync(metricsFile)) return [];
try {
return JSON.parse(fs.readFileSync(metricsFile, "utf8"));
} catch {
return [];
}
}
export function reportMetrics() {
const metrics = getMetrics();
const hooks = [...new Set(metrics.map((m) => m.hook))];
console.log("\nHook Metrics (last 100 runs):");
hooks.forEach((hook) => {
const hookMetrics = metrics.filter((m) => m.hook === hook).slice(-100);
const success = hookMetrics.filter((m) => m.status === "success").length;
const avgDuration =
hookMetrics.reduce((sum, m) => sum + m.duration, 0) / hookMetrics.length;
console.log(
` ${hook}: ${success}/${hookMetrics.length} success, avg ${avgDuration.toFixed(0)}ms`,
);
});
}
Use this to detect trends. If a hook’s success rate drops suddenly, something’s wrong. If execution time spikes, you have a performance regression.
Practical Debugging on Windows: Real Issues and Solutions
When things go wrong in your hooks on Windows, debugging is different because you can’t just add console.log everywhere and expect terminal output to look the same. Windows terminals have different capabilities, encoding handling is more complex, and errors can manifest in ways that confuse developers used to Unix systems.
Debugging Hook Failures on Windows
If your hook fails silently on Windows but works on macOS, check these in order:
1. Check File Encoding – If your .mjs file uses UTF-16 LE with a BOM (Byte Order Mark), Node will struggle. Always save as UTF-8 without BOM. Most editors allow this in settings. VS Code: ensure "files.encoding": "utf8" and disabled BOM generation.
2. Check Spawned Process Encoding – When you spawn a child process and capture output, Windows might return UTF-16. Specify encoding explicitly:
const result = execSync(command, {
encoding: 'utf8',
stdio: 'pipe'
});
3. Check Path Handling in Conditionals – A common bug: checking if a path contains a specific substring without normalizing. path.includes('/dist/') fails if the path uses backslashes. Always normalize before checking:
const normalized = path.normalize(filePath);
if (normalized.includes('dist')) { ... }
4. Check Git Integration – If your hook calls git commands, remember git might use different line endings on Windows depending on core.autocrlf settings. When parsing git output, always trim and handle both CRLF and LF.
5. Check Timestamp Precision – Windows and Unix handle file timestamps differently. A file’s mtime on Windows might not match on Unix due to rounding. If you’re comparing timestamps, allow a 1-second tolerance.
Windows-Specific Environment Variables
Your hook should be aware of these Windows environment variables that affect behavior:
TEMPandTMP: Windows temporary directory (Unix uses/tmp)USERPROFILE: User’s home directory on Windows (Unix uses$HOME)NUMBER_OF_PROCESSORS: CPU count on Windows (Unix usesnproc)PATHEXT: File extensions considered executable on Windows (determines which programs can be run without extension)COMSPEC: The shell to use (cmd.exeon Windows)
Your hook might check process.env.COMSPEC to determine if it’s on Windows, or use process.platform === 'win32' which is more reliable.
Creating a Windows-Proof Hook Checklist
Before considering a hook “production-ready” on Windows:
- [ ] All paths use
path.join()orpath.resolve(), not string concatenation - [ ] All
execSync()orspawn()calls specifyencoding: 'utf8' - [ ] All regex patterns handle both
\r\nand\n - [ ] All file I/O uses UTF-8 encoding explicitly
- [ ] All temporary files created in
os.tmpdir(), not/tmp - [ ] Shell scripts replaced with
.mjsequivalents or run vianode script.jsinstead of./script.sh - [ ] All async operations handle Windows slower I/O (increased timeouts where needed)
- [ ] Metrics/logs capture platform information for debugging
- [ ] Test on actual Windows machine (VM acceptable), not just “Windows in WSL”
This checklist is your Windows quality bar. If something isn’t checked, it’s not production-ready.
Conclusion: Windows Isn’t Weird, It’s Just Different
The biggest misconception about Windows and hook development is that Windows is weird or broken. It’s not. Windows is different. It has different conventions, different APIs, different expectations. Unix developers are often surprised by this because they’ve spent years treating Unix conventions as universal. They’re not.
Once you accept that Windows is a legitimate platform with legitimate design constraints, the solutions become obvious. You normalize paths because Windows uses backslashes. You use shell: true because Windows needs cmd.exe. You handle line endings because Windows uses CRLF. These aren’t workarounds—they’re correct cross-platform development.
Master these recipes and you’ll stop thinking about “Windows support” as a special case. You’ll think about it as the default. Your hooks will work on Windows, macOS, and Linux, and you won’t have to stress about which platform you’re testing on. That’s the goal.
The payoff extends beyond Windows compatibility. By designing for Windows constraints, you design for robustness. The path normalization that fixes Windows backslashes also prevents bugs on systems with complex paths. The timeout handling that prevents Windows hangs also prevents Unix hangs. The structured logging that helps Windows debugging also helps any platform debugging. You’re building better infrastructure for everyone.
Your team deserves tools that work reliably across platforms. Your Windows developers shouldn’t have to apologize for their OS. Your macOS developers shouldn’t assume their tools magically work everywhere. Your Linux engineers shouldn’t have to maintain separate scripts.
Write hooks once. Test on all platforms. Deploy with confidence. That’s the promise of these recipes.
-iNet