<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Tohru</title>
    <description>The latest articles on DEV Community by Tohru (@tohrusu).</description>
    <link>https://dev.to/tohrusu</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4207971%2Fe50b643f-c0b9-4b52-b8a4-b675005c4674.jpg</url>
      <title>DEV Community: Tohru</title>
      <link>https://dev.to/tohrusu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tohrusu"/>
    <language>en</language>
    <item>
      <title>When Git Filenames Break Windows Checkouts — How to Catch Them in CI?</title>
      <dc:creator>Tohru</dc:creator>
      <pubDate>Mon, 26 Oct 2026 07:19:58 +0000</pubDate>
      <link>https://dev.to/tohrusu/when-git-filenames-break-windows-checkouts-how-to-catch-them-in-ci-l70</link>
      <guid>https://dev.to/tohrusu/when-git-filenames-break-windows-checkouts-how-to-catch-them-in-ci-l70</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Consider these three paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assets/Logo.svg
assets/logo.svg
docs/NUL.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's nothing unusual about them on a case-sensitive Linux filesystem. On Windows, however, they introduce two problems.&lt;/p&gt;

&lt;p&gt;The two SVG files differ only in capitalization, so they may conflict on a case-insensitive filesystem. Meanwhile, &lt;code&gt;NUL&lt;/code&gt; is a reserved device name on Windows, and adding &lt;code&gt;.txt&lt;/code&gt; doesn't make it valid.&lt;/p&gt;

&lt;p&gt;Git can track all three paths. Whether another system can check them out is a different question.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproducing the Problem
&lt;/h2&gt;

&lt;p&gt;We'll use a disposable repository on Linux. You'll need Git, Python 3.11 or newer, and a case-sensitive filesystem.&lt;/p&gt;

&lt;p&gt;If you're using WSL, create the repository somewhere under your Linux home directory rather than on a Windows-mounted drive.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;repoport-demo
&lt;span class="nb"&gt;cd &lt;/span&gt;repoport-demo
git init &lt;span class="nt"&gt;-q&lt;/span&gt;

&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; assets docs

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'first\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; assets/Logo.svg
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'second\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; assets/logo.svg
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'third\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; docs/NUL.txt

git add &lt;span class="nt"&gt;--&lt;/span&gt; assets/Logo.svg assets/logo.svg docs/NUL.txt
git ls-files
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final command lists the three paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assets/Logo.svg
assets/logo.svg
docs/NUL.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That's enough to check for portability problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking the Filenames
&lt;/h2&gt;

&lt;p&gt;For this example, I'll use &lt;a href="https://github.com/TohruSu/RepoPort" rel="noopener noreferrer"&gt;RepoPort&lt;/a&gt;, a small Python CLI I maintain for checking Git filename portability.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;First, create a virtual environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install RepoPort v0.1.0 from its pinned release commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"git+https://github.com/TohruSu/RepoPort.git@f6e7d0cc72f284d65a889a005a991f84c8684c38"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now check the repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; repoport check &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For our three files, the output is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[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).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command exits with code &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Although three paths were checked, there are only two findings: one collision involving the SVG files and one Windows-reserved filename.&lt;/p&gt;

&lt;p&gt;The important part is that we caught both problems on Linux, without needing to attempt a Windows checkout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Look at the Git Index?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Git already provides a way to inspect the paths it tracks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git ls-files &lt;span class="nt"&gt;--cached&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="nt"&gt;--full-name&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; :/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--cached&lt;/code&gt; selects entries from the Git index.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-z&lt;/code&gt; separates paths with NUL bytes, so unusual filenames containing newlines can be parsed reliably.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--full-name&lt;/code&gt; reports paths relative to the repository root.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;:/&lt;/code&gt; selects paths from the repository root, even when the command is invoked from a subdirectory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The NUL-delimited format isn't particularly readable in a terminal, but it's a good interface for a program.&lt;/p&gt;

&lt;p&gt;There's another detail that's easy to miss: &lt;strong&gt;collisions can occur in directory names, not just filenames.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Consider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/a.py
SRC/b.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither complete path is the same as the other. But on a case-insensitive filesystem, &lt;code&gt;src&lt;/code&gt; and &lt;code&gt;SRC&lt;/code&gt; may refer to the same directory.&lt;/p&gt;

&lt;p&gt;RepoPort handles this by checking directory prefixes as well as complete paths.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;These comparisons catch useful classes of portability problems, though filesystem-specific Unicode behavior can be more complicated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing the Names
&lt;/h2&gt;

&lt;p&gt;Back in our example repository, we can give the files unambiguous names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; assets/logo.svg assets/logo-small.svg
git &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; docs/NUL.txt docs/null-device.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the check again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; repoport check &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;repoport: 3 tracked path(s), 0 finding(s).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command exits with code &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Once those references are updated, commit the renames so everyone receives the corrected filenames.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the Check in GitHub Actions
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Add &lt;code&gt;.github/workflows/filename-portability.yml&lt;/code&gt; to your repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Filename portability&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;filenames&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-python@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.11'&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install RepoPort v0.1.0&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python -m pip install "git+https://github.com/TohruSu/RepoPort.git@f6e7d0cc72f284d65a889a005a991f84c8684c38"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check Git-index tracked paths&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python -m repoport check .&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workflow checks the index populated by &lt;code&gt;actions/checkout&lt;/code&gt; for the revision under test.&lt;/p&gt;

&lt;p&gt;RepoPort returns one of three exit codes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No portability findings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Portability problems found&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Usage or execution error&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A nonzero exit code causes the checking step to fail.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing the Workflow
&lt;/h3&gt;

&lt;p&gt;I also ran this workflow in a separate private GitHub repository to check that it behaved as expected on GitHub-hosted Ubuntu runners.&lt;/p&gt;

&lt;p&gt;The first commit contained our original problematic paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assets/Logo.svg
assets/logo.svg
docs/NUL.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checkout, Python setup, and RepoPort installation all succeeded. The filename check then reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;repoport: 5 tracked path(s), 2 finding(s).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check exited with code &lt;code&gt;1&lt;/code&gt;, and the workflow failed.&lt;/p&gt;

&lt;p&gt;There are five tracked paths here rather than three because the test repository also contains a README and the workflow file itself.&lt;/p&gt;

&lt;p&gt;For the second run, I renamed the two problematic files and committed the fix. The workflow configuration stayed unchanged.&lt;/p&gt;

&lt;p&gt;The next run reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;repoport: 5 tracked path(s), 0 finding(s).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This time, the workflow passed.&lt;/p&gt;

&lt;p&gt;That covers both sides of the integration: rejecting the known filename problems and accepting the corrected paths.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The example above uses GitHub-hosted Ubuntu runners. For self-hosted runners, check the runtime requirements of the referenced Actions versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Few Limits Worth Knowing
&lt;/h2&gt;

&lt;p&gt;RepoPort checks the filenames recorded in Git's index, not everything that might affect cross-platform compatibility.&lt;/p&gt;

&lt;p&gt;For example, it doesn't inspect untracked files or recurse into submodule contents. It also doesn't validate symlink targets or path-length limits.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So a clean result is best understood as &lt;strong&gt;no problems found by these checks&lt;/strong&gt;, not a guarantee that the repository will work everywhere.&lt;/p&gt;

&lt;p&gt;It's still worth running actual checkout and build tests on the operating systems your project supports.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;More importantly, it makes filename portability something the repository checks automatically rather than something contributors have to discover themselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://git-scm.com/docs/git-ls-files" rel="noopener noreferrer"&gt;Git documentation: git-ls-files&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file" rel="noopener noreferrer"&gt;Microsoft: Naming Files, Paths, and Namespaces&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/pull-requests/reference/status-checks" rel="noopener noreferrer"&gt;GitHub: Required status checks&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/TohruSu/RepoPort/releases/tag/v0.1.0" rel="noopener noreferrer"&gt;RepoPort v0.1.0&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>git</category>
      <category>tutorial</category>
      <category>python</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
