Skip to content

About

Audit Git-tracked paths for Windows checkout hazards

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

winpathlint

Read-only Windows checkout compatibility audit for every Git-index tracked path, not just staged changes. Small Go CLI, standard library only. No network access, hooks, renames, working-tree writes, or external Go dependencies.

Requires Git on PATH to run. Source builds require Go 1.24+.

30-second quickstart

From a PowerShell checkout, install and scan the repository:

$env:CGO_ENABLED = '0'
go install github.com/otrumb/winpathlint@v0.2.0
winpathlint --repo (Get-Location).Path --format json
$LASTEXITCODE

Interpret the result: 0 means clean, 1 means findings need remediation, and 2 means the command or repository could not be processed. For a source checkout without Go installation, download the Windows ZIP below, extract it, and run the same winpathlint --repo ... --format json command.

CI before/after

The CI job runs the same built CLI as the quickstart after Go tests, vet, and build. A concrete remediation looks like this:

Before remediation: tracked path `NUL.txt` -> JSON rule `reserved_device_name`, exit 1.
Remediation: rename `NUL.txt` to `nul-report.txt`, stage the rename, and rerun CI.
After remediation: no findings (`[]`), exit 0; Go test, vet, and build still pass.

The example describes the CLI result and existing .github/workflows/ci.yml steps. It does not claim that CI changes files or performs remediation.

Install v0.2.0 (PowerShell)

The commands below target v0.2.0, available after its tag and release are published. Preparing this repository locally does not publish either.

Download a Windows binary

Open the v0.2.0 release and download SHA256SUMS plus one ZIP into the same directory:

  • winpathlint_v0.2.0_windows_amd64.zip for x64 Windows.
  • winpathlint_v0.2.0_windows_arm64.zip for ARM64 Windows.

Verify before extracting; change $archive for ARM64:

$archive = 'winpathlint_v0.2.0_windows_amd64.zip'
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant()
if (@(Get-Content -LiteralPath SHA256SUMS) -cnotcontains "$hash  $archive") {
    throw 'Checksum mismatch: do not extract or run this download'
}
Expand-Archive -LiteralPath $archive -DestinationPath .\winpathlint-v0.2.0
.\winpathlint-v0.2.0\winpathlint.exe --help
.\winpathlint-v0.2.0\winpathlint.exe --repo 'C:\src\my-project' --format json

Each ZIP contains winpathlint.exe and LICENSE at its root. Optionally add the extracted directory to your user PATH. Checksums detect damaged or changed downloads; they are not signatures or independent proof of publisher identity.

Install with Go

$env:CGO_ENABLED = '0'
go install github.com/otrumb/winpathlint@v0.2.0

Go installs into GOBIN, or bin under GOPATH when GOBIN is unset (normally $HOME\go\bin). Add that directory to PATH, then run winpathlint --help.

Build and run (PowerShell)

$env:CGO_ENABLED = '0'
go build -trimpath -o winpathlint.exe .
.\winpathlint.exe --help
.\winpathlint.exe --repo 'C:\src\my-project'
$LASTEXITCODE
.\winpathlint.exe --repo 'C:\src\my-project' --format json --max-path 200

--repo defaults to .. A subdirectory still audits the whole index. Paths in reports are relative to the repository root, always with / separators. Bare repositories and Git metadata directories are rejected. Linked worktrees use their own index. An empty index is clean; no commit is required.

Exit Meaning
0 Clean, or help requested
1 One or more findings
2 Invalid arguments, not a worktree, Git failure, invalid index encoding, or output failure

Help and errors go to stderr. Findings go to stdout. Use the built binary for exit-code checks: go run wraps nonzero program exits.

Rules

Rule Detection
case_collision Different spellings of the same uppercased path prefix, including directory components
reserved_device_name CON, PRN, AUX, NUL, COM1-9, LPT1-9, including superscript 1/2/3 device digits, case-insensitive, in every component and before the first extension
illegal_character Any of `< > : " \
trailing_dot_or_space A component ending in ASCII dot or space
control_character U+0001 through U+001F in a component; NUL cannot occur in Git paths
path_too_long Repository-relative path exceeds --max-path UTF-16 code units, including separators

--max-path defaults to 240, must be positive, and is a portable policy budget, not the absolute Win32 MAX_PATH limit. Equality is allowed. Account for your checkout root length when choosing a budget. Supplementary Unicode characters count as two UTF-16 units. Reserved-name checks trim spaces before the first dot, so CON .txt is also reported.

Stable output

Both formats sort by path, rule, component, then related, using Go string byte order. Duplicate index entries (such as merge stages) and identical findings collapse. Each collision points to the first byte-sorted tracked path with that uppercased prefix; reports do not enumerate every pair.

Text has four tab-separated columns: rule, quoted path, quoted component, quoted related path. Strings use Go quoting, escaping controls and newlines. Empty optional columns appear as "". Clean text output is empty.

JSON is an array, never null; clean output is []. Optional fields are omitted.

[{"path":"NUL.txt","rule":"reserved_device_name","component":"NUL.txt"},{"path":"foo","rule":"case_collision","component":"foo","related":"Foo"}]

Scope and limitations

  • Reads git -C <repo> ls-files --full-name -z -- :/. NUL separation preserves whitespace, tabs and newlines. The root pathspec includes paths outside the supplied subdirectory. Git has a one-minute operation budget.
  • Index only: includes tracked paths missing from disk and unresolved index stages; excludes untracked files and history. Does not inspect file contents.
  • Submodule entries are audited as paths; submodule indexes are not traversed.
  • UTF-8 paths are required. Invalid UTF-8 is an error, not silently replaced in JSON. Git environment variables, including alternate-index settings, apply.
  • Case matching uses Go Unicode uppercase, not exact NTFS upcase tables or per-directory case-sensitivity settings. No Unicode normalization or 8.3 alias simulation. Some filesystem-specific collisions may be missed or overreported.
  • Not a checkout guarantee: no component-length limit, symlink policy, ACL, filesystem capacity, Git-specific protected-name, or absolute-path audit.
  • No remediation, rename mode, revision selection, hook installation, remote access, or repository mutation. Git must already be installed.

Development

gofmt -l .
go test -shuffle=on -count=1 -cover ./...
go vet ./...
$env:CGO_ENABLED = '0'
go build -trimpath -o winpathlint.exe .
.\winpathlint.exe --repo . --format json

Tests create isolated temporary Git repositories. Plumbing inserts collisions and Windows-invalid names directly into the index, without trying to create those paths on Windows. Tests cover index immutability, subdirectory scans, invalid arguments, writer failures, corrupt indexes, and built-binary exits. Race instrumentation is deliberately excluded: it requires cgo on Windows and this project has a no-cgo contract. CI runs Windows checks with pinned actions.

Local release packaging

From the repository root, use a new output directory whose parent exists:

$output = Join-Path $env:TEMP ('winpathlint-' + [guid]::NewGuid())
.\scripts\test-package.ps1 -Version v0.2.0 -OutputDirectory $output

This builds Windows amd64 and arm64 with CGO_ENABLED=0, verifies ZIP members, license bytes, PE architectures and SHA256 checksums, then repeats both builds to check identical archive hashes. Output contains both ZIPs and SHA256SUMS. scripts/package.ps1 is the shared local/workflow packager. It rejects invalid versions and existing output directories rather than overwriting artifacts. ZIP timestamps are fixed; builds omit local paths and VCS metadata. Repeatability is checked within the same Go/.NET toolchain, not promised across toolchain versions.

The release workflow runs on pushed v*.*.* tags or manual dispatch with a tag input. It validates strict vX.Y.Z versions, checks out the event's exact SHA (or the existing tag on manual dispatch), runs quality gates and packaging checks, then publishes the three verified assets. Build has read-only permissions; only the separate publish job can write releases. If no release exists, publish creates a draft. If a draft exists, reruns reuse it and replace the exact three assets before verifying them and publishing. A published release blocks reruns; the workflow never overwrites or deletes releases. Local checks never create a remote, tag, or release.

Release discovery uses the paginated releases list, including drafts, matched by tag_name; the release-by-tag REST endpoint excludes drafts. Test the actual workflow lookup locally without network access: ./scripts/test-release.ps1.

For the failed v0.2.0 run, push the workflow fix to main, then manually run Windows release from main with tag set to v0.2.0. This uses the fixed workflow while building the unchanged tag at e15580f. Do not move the tag or delete its draft. Rerunning the original failed run would reuse its old workflow, not this fix. After recovery, verify both ZIPs and SHA256SUMS on the published release. A later rerun intentionally refuses to overwrite that published release.

License

MIT. Copyright (c) 2026 Ngoc Trung.

About

Audit Git-tracked paths for Windows checkout hazards

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages