Skip to content

Cross-platform repositories

Repositories aim to clone, build, and test on Windows, macOS, and Linux, and avoid filenames Windows cannot check out.

Updated View as MarkdownView on Github

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 README says so. Keep the clone on WSL’s Linux file system, under /home/<user>/, not under /mnt/c/. A clone on /mnt/c brings 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 README and move on.
  • Always follow the filename guidance. Proportion does not apply to it. A filename Windows cannot create breaks git clone for a whole platform, and it costs almost nothing to avoid. Rename a bad path when you find it, so git checkout works for everyone. See filenames Windows cannot check out.
  • Keep scripts portable. This covers the scripts in package.json and 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:

  1. Characters. Do not use <, >, :, ", \, |, ?, *, or a control character such as a tab or newline in any file or directory name.
  2. Endings. Do not end a name with a space or a period.
  3. Reserved device names. Do not name a file or directory CON, PRN, AUX, NUL, COM1 to COM9, LPT1 to LPT9, or the superscript forms such as COM¹ and LPT². Git for Windows also refuses LPT0. This applies in any letter case, with or without an extension: aux.md, Nul.txt, and con.tar.gz are all rejected.
  4. 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 named nul. Write 2>/dev/null in Bash and 2>$null in PowerShell.
  • A generated file carries a timestamp such as report-12:30.csv or 2026-09-29T10:15:00.log. The colon is the problem.
  • A module is named after what it does: src/aux/, con.ts, or prn.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.md and readme.md can 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 as Utils/ and utils/.
  • Match case exactly in imports, includes, .gitignore entries, and configuration. import x from "./Utils" for a file named utils.ts works 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 true

Line endings. Add a .gitattributes file at the repository root so every operating system checks out the same bytes:

.gitattributesplaintext
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf

Text 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 100755

Symlinks. 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, or cargo, 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, or java.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 README and start each script with #!/usr/bin/env bash. macOS ships BSD versions of sed, date, and stat, 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-env and rimraf as dev dependencies. They set variables and delete paths the same way on every system.
  • rm -rf works on some Windows machines only because Git’s Unix tools happen to be on PATH. Do not rely on it.
  • Single quotes are the quiet failure. cmd.exe passes 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 that package.json calls.

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:

.github/workflows/portable-paths.ymlyaml
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/v1

nt-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:

  1. On macOS, Linux, or WSL, clone the repository and check out the branch.
  2. Rename the file or directory with git mv, then update every reference to it. For a case collision, merge the two paths into one.
  3. Run the portable paths check again until it reports nothing.
  4. 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

Navigation

Type to search…

↑↓ navigate↵ selectEsc close