Our engineers work on Windows, macOS, and Linux machines, so a repository should work on all three. One filename that only Unix accepts can stop every Windows teammate from cloning it. This page is a reminder to support the environments we have, not a strictly enforced rule. Apply it in proportion: fix what does not take significant time, such as a change that fits in the same pull request, and record the rest in the README. The filename guidance is the exception, and always applies.
The standard
- Aim for a repository that builds, tests, and runs on Windows and Unix. Unix here means macOS and Linux. They differ from each other, so do not assume one Unix.
- Windows engineers develop natively by default. A repository should not need WSL to work on Windows.
- Make an exception for a hard dependency, and use WSL2 for it. A dependency, native package, or tool sometimes runs only on Linux or macOS, and dropping it is not practical. A Windows engineer then works in WSL2 for that repository, and its
READMEsays so. Keep the clone on WSL’s Linux file system, under/home/<user>/, not under/mnt/c/. A clone on/mnt/cbrings the Windows limits back and is slow. See working across file systems. - Apply it in proportion. If a fix does not take significant time, such as one that fits in the same pull request, make it. Otherwise record the gap in the
READMEand move on. - Always follow the filename guidance. Proportion does not apply to it. A filename Windows cannot create breaks
git clonefor a whole platform, and it costs almost nothing to avoid. Rename a bad path when you find it, sogit checkoutworks for everyone. See filenames Windows cannot check out. - Keep scripts portable. This covers the
scriptsinpackage.jsonand any other command a person or CI runs, not only the files in the repository. See Scripts and tooling. - Say which systems a repository supports. Record the supported systems, and any WSL2 requirement, in the
README. A repository that needs one system, such as NinjaTrader 8’s WPF and NinjaScript code, which needs Windows, says so there too. The filename guidance still applies, because native Windows teammates, reviewers, CI, and coding agents on other systems still clone the repository. - The check is advisory by default. A local hook and a CI job point out a problem early. A repository may make the CI job fail, but this standard does not require it, and the check is not a quality gate.
ADR-0007 records the native Windows default and the WSL2 fallback.
Filenames Windows cannot check out
Git on macOS and Linux accepts names that Git on Windows refuses. The push succeeds. The next Windows clone fails:
error: invalid path 'aux.txt'
fatal: unable to checkout working tree
warning: Clone succeeded, but checkout failed.The clone leaves an empty working tree. Every Windows teammate stays blocked until someone renames the file. An engineer on WSL2 does not see the failure, so the name goes unnoticed until someone else clones. Avoid committing a path that breaks these guidelines:
- Characters. Do not use
<,>,:,",\,|,?,*, or a control character such as a tab or newline in any file or directory name. - Endings. Do not end a name with a space or a period.
- Reserved device names. Do not name a file or directory
CON,PRN,AUX,NUL,COM1toCOM9,LPT1toLPT9, or the superscript forms such asCOM¹andLPT². Git for Windows also refusesLPT0. This applies in any letter case, with or without an extension:aux.md,Nul.txt, andcon.tar.gzare all rejected. - Case. No two paths differ only by letter case. See Case.
These names usually arrive by accident:
- A Windows-style redirect such as
2>nul, run in a Unix shell by a person or a coding agent, creates a real file namednul. Write2>/dev/nullin Bash and2>$nullin PowerShell. - A generated file carries a timestamp such as
report-12:30.csvor2026-09-29T10:15:00.log. The colon is the problem. - A module is named after what it does:
src/aux/,con.ts, orprn.py.
Windows also reserves other names, and Git refuses some names on any system, such as .git. The Windows naming rules list them all.
Case
Windows and macOS file systems ignore letter case by default while preserving it. Linux does not. Unix does not mean case-sensitive.
README.mdandreadme.mdcan both exist in Git. On checkout to Windows or macOS one overwrites the other, and Git then reports a modification that no commit can fix. The same holds for directories, such asUtils/andutils/.- Match case exactly in imports, includes,
.gitignoreentries, and configuration.import x from "./Utils"for a file namedutils.tsworks on Windows and macOS and fails on a Linux CI runner.
Keep paths short
Windows tools commonly enforce a 260-character limit on the whole path, and the clone location counts toward it. Deeply nested paths can hit it. The limit does not apply inside WSL on the Linux file system. On native Windows, turn on long paths in Git:
git config --global core.longpaths trueLine endings, executable bits, and symlinks
Line endings. Add a .gitattributes file at the repository root so every operating system checks out the same bytes:
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlfText files stay LF in the repository and in every working tree. Windows batch files need CRLF. A shell script with CRLF endings fails on Linux and macOS with env: 'bash\r': No such file or directory. Do not rely on each developer’s core.autocrlf. In a repository that already has mixed endings, run git add --renormalize . and commit the result.
Executable bits. Windows file systems have no executable bit. A script added from Windows is committed as mode 100644, and ./scripts/build.sh then fails on Linux and macOS with Permission denied. Set the bit in Git and check it:
git update-index --chmod=+x scripts/build.sh
git ls-files --stage scripts/build.sh # the mode reads 100755Symlinks. Windows creates a real symlink only with Developer Mode or elevated rights, and only when core.symlinks is on. Otherwise Git writes a plain text file that holds the link target. Avoid committing symlinks. Use an import or a generated copy instead. The CLAUDE.md file imports AGENTS.md with @AGENTS.md rather than linking to it. See agent instructions.
Scripts and tooling
- Put entry points in the repository’s build tool from team standards, such as
pnpm,sbt,uv,dotnet, orcargo, so a command means the same thing on every machine. - Build paths with the language’s path API, such as
node:path,pathlib,Path.Combine, orjava.nio.file.Path. Do not join paths by string concatenation, and do not hardcode drive letters or/tmp. - Bash scripts run on Windows through Git Bash or WSL. Name Bash as a prerequisite in the
READMEand start each script with#!/usr/bin/env bash. macOS ships BSD versions ofsed,date, andstat, whose options differ from GNU. Use only the options both share, or check for the tool you need.
Scripts in package.json
pnpm and npm run each script through cmd.exe on Windows and through sh on macOS, Linux, and WSL. Shell syntax that works on Unix often fails on Windows, sometimes without an error. An engineer on WSL2 does not see this, so write each script so both shells run it the same way:
Fails on Windows in package.json |
Use instead |
|---|---|
FOO=bar node build.js |
cross-env FOO=bar node build.js |
rm -rf dist |
rimraf dist |
textlint 'src/**/*.md' |
textlint \"src/**/*.md\" |
echo $HOME, $(node -v), 2>/dev/null |
A Node script |
- Add
cross-envandrimrafas dev dependencies. They set variables and delete paths the same way on every system. rm -rfworks on some Windows machines only because Git’s Unix tools happen to be onPATH. Do not rely on it.- Single quotes are the quiet failure.
cmd.exepasses them to the tool as part of the argument, so a quoted glob such as'src/**/*.md'matches no files and the check passes having checked nothing. Use escaped double quotes inside the JSON string. &&and||work in both shells. Anything beyond one or two commands, or that needs variables, substitution, or redirects, belongs in a Node script thatpackage.jsoncalls.
The same applies to every command a person or CI runs: Makefile and Justfile recipes, Git hooks, and the commands a README tells people to copy.
Check for bad paths
The check below is advisory by default, and it is not part of quality gates. See blocking versus advisory. It catches a mistake while it is cheap to fix.
The check is a shared action, check-portable-paths in NT-NinjaTrader/gh-actions. It reads paths from Git, so it needs no Windows file system, and it flags every filename problem above: forbidden characters, names that end in a space or a period, reserved device names, and paths that differ only by case. Call it from a workflow:
name: Portable paths
on:
pull_request:
permissions:
contents: read
jobs:
portable-paths:
runs-on: nt-default
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: NT-NinjaTrader/gh-actions/.github/actions/check-portable-paths@check-portable-paths/v1nt-default is the self-hosted runner label NinjaTrader repositories use, and team standards says CI runs on self-hosted runners. Other standards pages show ubuntu-latest for generic examples. Any runner with Bash, Git, and Perl works.
By default the action annotates the run and writes a job summary that lists every path, then passes. That is the reminder. Set fail-on-violation: 'true' to fail the step. Whether a failed step blocks a merge is up to the repository’s branch protection, and this standard does not require it. The action also takes working-directory, and it sets the violations and has_violations outputs.
You can also run the script behind the action on your machine. It needs Bash, which Windows engineers get from Git Bash or WSL. From a checkout of gh-actions, run bash .github/scripts/check-portable-paths/check-portable-paths.sh from the root of the repository you want to check. It prints plain lines. To run it before each commit, add it to the repository’s hook manager, such as pre-commit or Husky. See task automation.
A build on the other operating system is the only full proof, but nothing requires one. Try the repository on a second system after you change a script, a build step, or a package.json command.
Fix a repository that already has a bad name
A bad name in the repository blocks Windows checkouts, so fix it from a machine that can hold it:
- On macOS, Linux, or WSL, clone the repository and check out the branch.
- Rename the file or directory with
git mv, then update every reference to it. For a case collision, merge the two paths into one. - Run the portable paths check again until it reports nothing.
- Open a pull request for the fix. Once it merges, Windows teammates clone again.
The bad name stays in older commits, so a Windows checkout of one of them can still fail. Inspect old commits on Linux or WSL. Do not rewrite shared history to remove the name.
See also
- Team standards for the build tool and quality tooling each surface uses.
- Quality gates for the difference between a blocking gate and an advisory check.
- Agent instructions for why
CLAUDE.mdimportsAGENTS.md. gitattributesfor the full attribute reference.check-portable-pathsfor the shared action, and pull request 184 for its review.