A Git repository can check out without trouble on Linux and fail on Windows before the build even starts. One possible cause is a filename that the destination filesystem can't handle.
Consider these three paths:
assets/Logo.svg
assets/logo.svg
docs/NUL.txt
There's nothing unusual about them on a case-sensitive Linux filesystem. On Windows, however, they introduce two problems.
The two SVG files differ only in capitalization, so they may conflict on a case-insensitive filesystem. Meanwhile, NUL is a reserved device name on Windows, and adding .txt doesn't make it valid.
Git can track all three paths. Whether another system can check them out is a different question.
In this article, we'll reproduce the problem, look at how Git stores tracked filenames, and set up a GitHub Actions check to catch these issues before they reach other contributors.
Reproducing the Problem
We'll use a disposable repository on Linux. You'll need Git, Python 3.11 or newer, and a case-sensitive filesystem.
If you're using WSL, create the repository somewhere under your Linux home directory rather than on a Windows-mounted drive.
mkdir repoport-demo
cd repoport-demo
git init -q
mkdir -p assets docs
printf 'first\n' > assets/Logo.svg
printf 'second\n' > assets/logo.svg
printf 'third\n' > docs/NUL.txt
git add -- assets/Logo.svg assets/logo.svg docs/NUL.txt
git ls-files
The final command lists the three paths:
assets/Logo.svg
assets/logo.svg
docs/NUL.txt
We've added the files to Git's index, so their names are now part of the staged state. We haven't committed anything yet.
That's enough to check for portability problems.
Checking the Filenames
For this example, I'll use RepoPort, a small Python CLI I maintain for checking Git filename portability.
It looks for case and Unicode normalization collisions, Windows-reserved names, forbidden characters, and trailing spaces or periods. It reads the Git index without modifying the repository.
First, create a virtual environment:
python3 -m venv .venv
source .venv/bin/activate
Install RepoPort v0.1.0 from its pinned release commit:
python -m pip install "git+https://github.com/TohruSu/RepoPort.git@f6e7d0cc72f284d65a889a005a991f84c8684c38"
Now check the repository:
python -m repoport check .
For our three files, the output is:
[case-collision] 'assets/Logo.svg', 'assets/logo.svg': These paths collide after NFC normalization and Unicode case folding; choose names that remain distinct without case sensitivity.
[windows-reserved-name] 'docs/NUL.txt': Windows reserves this device name, including with extensions; rename this file or directory.
repoport: 3 tracked path(s), 2 finding(s).
The command exits with code 1.
Although three paths were checked, there are only two findings: one collision involving the SVG files and one Windows-reserved filename.
The important part is that we caught both problems on Linux, without needing to attempt a Windows checkout.
Why Look at the Git Index?
You could write a script that walks through a directory and examines every filename. But the files currently present on disk aren't necessarily the files Git will include in a commit.
An untracked file might exist locally without being part of the repository. Conversely, a tracked path can remain in the index even when its working-tree file is missing.
Git already provides a way to inspect the paths it tracks:
git ls-files --cached -z --full-name -- :/
A few details matter here:
-
--cachedselects entries from the Git index. -
-zseparates paths with NUL bytes, so unusual filenames containing newlines can be parsed reliably. -
--full-namereports paths relative to the repository root. -
:/selects paths from the repository root, even when the command is invoked from a subdirectory.
The NUL-delimited format isn't particularly readable in a terminal, but it's a good interface for a program.
There's another detail that's easy to miss: collisions can occur in directory names, not just filenames.
Consider:
src/a.py
SRC/b.py
Neither complete path is the same as the other. But on a case-insensitive filesystem, src and SRC may refer to the same directory.
RepoPort handles this by checking directory prefixes as well as complete paths.
For case collisions, it normalizes paths to Unicode NFC and applies case folding before comparing them. It also reports paths that become identical under NFC normalization alone.
These comparisons catch useful classes of portability problems, though filesystem-specific Unicode behavior can be more complicated.
Fixing the Names
Back in our example repository, we can give the files unambiguous names:
git mv -- assets/logo.svg assets/logo-small.svg
git mv -- docs/NUL.txt docs/null-device.txt
Run the check again:
python -m repoport check .
This time:
repoport: 3 tracked path(s), 0 finding(s).
The command exits with code 0.
For a real project, renaming the files is only part of the fix. You'll also want to check any imports, asset references, configuration files, or documentation that uses the old paths.
Once those references are updated, commit the renames so everyone receives the corrected filenames.
Running the Check in GitHub Actions
Checking filenames before a local commit is useful. Running the same check in CI gives the repository another opportunity to catch a problem before merging.
For this particular task, Ubuntu is a sensible runner choice: it can check out filenames that would be invalid on Windows, then inspect them without requiring a Windows working tree.
Add .github/workflows/filename-portability.yml to your repository:
name: Filename portability
on: [push, pull_request]
permissions:
contents: read
jobs:
filenames:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install RepoPort v0.1.0
run: python -m pip install "git+https://github.com/TohruSu/RepoPort.git@f6e7d0cc72f284d65a889a005a991f84c8684c38"
- name: Check Git-index tracked paths
run: python -m repoport check .
The workflow checks the index populated by actions/checkout for the revision under test.
RepoPort returns one of three exit codes:
| Code | Meaning |
|---|---|
0 |
No portability findings |
1 |
Portability problems found |
2 |
Usage or execution error |
A nonzero exit code causes the checking step to fail.
Testing the Workflow
I also ran this workflow in a separate private GitHub repository to check that it behaved as expected on GitHub-hosted Ubuntu runners.
The first commit contained our original problematic paths:
assets/Logo.svg
assets/logo.svg
docs/NUL.txt
Checkout, Python setup, and RepoPort installation all succeeded. The filename check then reported:
repoport: 5 tracked path(s), 2 finding(s).
The check exited with code 1, and the workflow failed.
There are five tracked paths here rather than three because the test repository also contains a README and the workflow file itself.
For the second run, I renamed the two problematic files and committed the fix. The workflow configuration stayed unchanged.
The next run reported:
repoport: 5 tracked path(s), 0 finding(s).
This time, the workflow passed.
That covers both sides of the integration: rejecting the known filename problems and accepting the corrected paths.
If you want a failed check to prevent pull requests from being merged, you'll also need to mark the job as a required status check in your repository settings. A failing workflow alone doesn't enforce that policy.
The example above uses GitHub-hosted Ubuntu runners. For self-hosted runners, check the runtime requirements of the referenced Actions versions.
A Few Limits Worth Knowing
RepoPort checks the filenames recorded in Git's index, not everything that might affect cross-platform compatibility.
For example, it doesn't inspect untracked files or recurse into submodule contents. It also doesn't validate symlink targets or path-length limits.
Unicode comparisons are another area where a conservative check is useful but not definitive. Different filesystems don't necessarily implement the same normalization and case-comparison rules. RepoPort treats filenames that aren't valid UTF-8 as an error rather than attempting to interpret them.
So a clean result is best understood as no problems found by these checks, not a guarantee that the repository will work everywhere.
It's still worth running actual checkout and build tests on the operating systems your project supports.
Final Thoughts
Filename problems are easy to overlook when most development happens on a single operating system. Git may accept a path that another contributor's filesystem cannot represent reliably.
Checking the Git index is a relatively small addition to a CI pipeline, and it can catch several of these problems before they turn into checkout failures or confusing working-tree behavior.
More importantly, it makes filename portability something the repository checks automatically rather than something contributors have to discover themselves.
Top comments (0)