]> vilimpoc.org git repositories - dotfiles/commitdiff
dotfiles: pin the native ninja ahead of the one VS bundles
authorMax Vilimpoc <max@vilimpoc.org>
Sat, 5 Sep 2026 22:27:05 +0000 (00:27 +0200)
committerMax Vilimpoc <max@vilimpoc.org>
Sat, 5 Sep 2026 22:27:05 +0000 (00:27 +0200)
Visual Studio ships an x64 ninja.exe even on ARM64. That matters more
than a one-off emulated tool would, because ninja is re-invoked for every
edge in the build graph - it is the one place emulation is paid over and
over rather than once.

New NinjaPath step finds a native ninja (winget's package directory, or
one already on the PATH that is not the VS copy), checks its PE machine
type actually matches the host, and moves its directory to the FRONT of
the user PATH. Add-ToUserPath grew a -Prepend switch for it, which also
moves an entry already present further down; appending stays the default.

Corrects the previous commit, which claimed VsDevCmd prepends the VS
directories and that the emulated ninja therefore always won inside a
Developer Command Prompt. It does not. VS adds them from
Common7\Tools\vsdevcmd\ext\cmake.bat with

    set "PATH=%PATH%;...\CMake\bin;...\CMake\Ninja"

which APPENDS, so they land at the very end of the composed PATH, behind
every machine and user entry - a native ninja anywhere on the user PATH
already beat it. Measured on an ARM64 box: the winget copy wins in a
fresh VsDevCmd session either way. This step is therefore insurance, not
the load-bearing fix: winget also appends its package directory, so
without it the margin rested on two append orders staying as they are,
one of them in a file Microsoft owns and revises.

The architecture audit now resolves against the PATH a NEW shell would
get - machine + user from the registry, then anything extra this process
carries (where a Developer prompt's VS directories live, kept last to
mirror how VsDevCmd appends them). Auditing $env:PATH reported state from
before the script ran, so it warned about a problem it had just fixed.

Verified end to end on ARM64: ninja resolves to the winget ARM64 build in
a fresh VsDevCmd session, CMake records it as CMAKE_MAKE_PROGRAM, and
`cmake -G Ninja` builds an ARM64 binary through the native ARM64 MSVC.
The audit now ends "Everything resolved to a native build, or to a listed
exception."

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ak6VzfGyaocrykX1LhNMwt

README.md
setup-windows-no-uac.ps1
setup-windows.bat

index 349a693f72dd9ffe2ce86652001cdd3363268172..f897f4b7a2c1f30b75db084a5b530f4657aaa25e 100644 (file)
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ Runs on **x64 and on ARM64** (Windows 11 on Arm). See
 | File | Purpose |
 | --- | --- |
 | `setup-windows.bat` | Entry point. Runs the winget installs, then launches the elevated half and prints its log, then runs the non-elevated script. |
-| `setup-windows-no-uac.ps1` | The non-elevated, per-user half: WinMerge and BinSkim on the user `PATH`, and the global git config (identity, plus `core.sshCommand`). Can also be run directly from an ordinary prompt. |
+| `setup-windows-no-uac.ps1` | The non-elevated, per-user half: WinMerge and BinSkim on the user `PATH`, the global git config (identity, plus `core.sshCommand`), the native `ninja` pinned ahead of the one Visual Studio bundles, and an architecture audit of everything provisioned. Can also be run directly from an ordinary prompt. |
 | `setup-windows-with-uac.ps1` | The elevated half, started via UAC by the batch file. Enables `ssh-agent`, installs the OpenSSH Client and Server capabilities and starts `sshd`, unpacks the `rsync-windows` release zip for this architecture (`rsync.exe` plus the `ssh.exe` it runs) into `C:\Tools\rsync` on the machine `PATH`, then installs Visual Studio Community with the required components, the WDK, and the Windows Performance Toolkit, and reports whether Intel VTune Profiler is present. Can also be run directly from an Administrator prompt. |
 | `setup-windows-7-test-env.bat` | Prepares a **Windows 7 VM** as a test target driven from the host by `VBoxManage guestcontrol`. Copy it into the guest and run it there; it is idempotent, so re-run it after any snapshot restore. The per-user half needs no UAC (crash-dialog suppression, no screen blanking, a staging directory, the shared folder on `Z:`); the machine-wide half is skipped with a notice unless run elevated inside the guest. It then reports what the box can actually test: DWM composition, printers, audio capture devices. |
 
@@ -326,13 +326,22 @@ how the `ninja` problem below was found.
 
 ### Two gotchas the audit surfaces
 
-- **Visual Studio bundles an x64 `ninja.exe` even on ARM64**, and `VsDevCmd.bat`
-  **prepends** the VS directories to `PATH`. So inside a Developer Command Prompt
-  — exactly where C++ builds happen — the emulated ninja wins over the native one
-  `winget install Ninja-build.Ninja` provides. It matters more than a one-off
-  tool would, because ninja is re-invoked for every edge in the build graph. Pass
-  `-DCMAKE_MAKE_PROGRAM=` the native one, or put its directory ahead of the VS
-  one. VS's bundled `cmake.exe` *is* ARM64, so only ninja has this problem.
+- **Visual Studio bundles an x64 `ninja.exe` even on ARM64.** It matters more
+  than a one-off tool would, because ninja is re-invoked for every edge in the
+  build graph — it is the one place emulation is paid over and over rather than
+  once. `winget install Ninja-build.Ninja` gets the native `ninja-winarm64`
+  build, and the `NinjaPath` step pins its directory to the **front** of the user
+  `PATH`. VS's bundled `cmake.exe` *is* ARM64, so only ninja has this problem.
+
+  On which copy wins inside a Developer Command Prompt: the native one. VS adds
+  its ninja from `Common7\Tools\vsdevcmd\ext\cmake.bat`, which does
+  `set "PATH=%PATH%;...\CMake\bin;...\CMake\Ninja"` — an **append**, so the VS
+  directories land at the very end of the composed `PATH`, behind every machine
+  and user entry. A native ninja anywhere on the user `PATH` already beats it,
+  measured on an ARM64 box. The `NinjaPath` step is therefore insurance rather
+  than the load-bearing fix: winget *appends* its package directory to the user
+  `PATH`, so without it the margin rests on two append orders staying as they
+  are, one of them in a file Microsoft owns and revises.
 - **Sysinternals ships every architecture in one zip**, and the ARM64 build is
   not the default-named exe. `ProcessExplorer.zip` contains `procexp.exe` (x86),
   `procexp64.exe` (x64) and `procexp64a.exe` (**ARM64**) — so run the `*64a.exe`
index 5d713c3ab815d36cea28578087eb55348b924d4a..3b67f0f0024132f5bf5c3ec70f7e82a767e11fdd 100644 (file)
@@ -40,7 +40,7 @@ param(
     # for several, dot-call the script or use -Command:
     #     .\setup-windows-no-uac.ps1 -Skip BinSkim,GitConfig
     #     powershell -File .\setup-windows-no-uac.ps1 -Skip BinSkim
-    [ValidateSet('WinMerge', 'VsWhere', 'BinSkim', 'GitConfig', 'ArchAudit')]
+    [ValidateSet('WinMerge', 'VsWhere', 'BinSkim', 'GitConfig', 'NinjaPath', 'ArchAudit')]
     [string[]] $Skip = @()
 )
 
@@ -73,19 +73,46 @@ function Write-Step([string]$Msg) {
     Write-Host "`n==> $Msg" -ForegroundColor Cyan
 }
 
-function Add-ToUserPath([string]$Dir) {
+function Add-ToUserPath {
     # HKCU PATH, not the process PATH: this must outlive the script. Idempotent,
     # and re-applied on every run so an entry lost to an unrelated PATH edit is
     # repaired without re-doing the install that put it there.
+    #
+    # -Prepend puts the directory FIRST and moves it there if it is already
+    # present further down, which is the difference between "on the PATH" and
+    # "the one that wins". Only for entries where that matters; appending is the
+    # polite default and stays the default.
+    param(
+        [string] $Dir,
+        [switch] $Prepend
+    )
     $user = [Environment]::GetEnvironmentVariable('Path', 'User')
     if (-not $user) { $user = '' }
-    if (($user -split ';') -contains $Dir) {
-        Write-Host "    $Dir already in user PATH."
+    # Compare trailing-backslash-insensitively: C:\x and C:\x\ are the same
+    # directory, and adding a second spelling of one is just noise.
+    $norm    = { param($s) $s.Trim().TrimEnd('\') }
+    $entries = @($user -split ';' | Where-Object { $_.Trim() })
+    $already = $entries | Where-Object { (& $norm $_) -eq (& $norm $Dir) }
+
+    if (-not $Prepend) {
+        if ($already) { Write-Host "    $Dir already in user PATH."; return }
+        $new = if ($user.Trim()) { $user.TrimEnd(';') + ';' + $Dir } else { $Dir }
+        [Environment]::SetEnvironmentVariable('Path', $new, 'User')
+        Write-Host "    Added $Dir to user PATH (restart your shell to pick it up)."
+        return
+    }
+
+    if ($already -and (& $norm $entries[0]) -eq (& $norm $Dir)) {
+        Write-Host "    $Dir already first in user PATH."
         return
     }
-    $new = if ($user.Trim()) { $user.TrimEnd(';') + ';' + $Dir } else { $Dir }
-    [Environment]::SetEnvironmentVariable('Path', $new, 'User')
-    Write-Host "    Added $Dir to user PATH (restart your shell to pick it up)."
+    $rest = $entries | Where-Object { (& $norm $_) -ne (& $norm $Dir) }
+    [Environment]::SetEnvironmentVariable('Path', (@($Dir) + $rest) -join ';', 'User')
+    if ($already) {
+        Write-Host "    Moved $Dir to the front of the user PATH (restart your shell to pick it up)."
+    } else {
+        Write-Host "    Added $Dir to the front of the user PATH (restart your shell to pick it up)."
+    }
 }
 
 function Add-WinMergeToUserPath {
@@ -341,6 +368,59 @@ function Set-GlobalGitConfig {
     Write-Host "    $version"
 }
 
+function Set-NativeNinjaFirst {
+    # Make the native ninja.exe the one that wins, including inside a Visual
+    # Studio Developer Command Prompt.
+    #
+    # WHY THIS IS INSURANCE RATHER THAN THE FIX. VS ships an x64 ninja.exe even on
+    # ARM64 and puts it on the PATH from
+    # Common7\Tools\vsdevcmd\ext\cmake.bat, which does:
+    #     set "PATH=%PATH%;...\CMake\bin;...\CMake\Ninja"
+    # That APPENDS - the VS directories land at the very end of the composed
+    # PATH, behind every machine and user entry. So a native ninja installed
+    # anywhere on the user PATH already beats it, and measurement on an ARM64 box
+    # confirms it does. (An earlier revision of this script claimed VsDevCmd
+    # prepends and that the VS copy therefore always won; that was wrong.)
+    #
+    # It is still worth pinning the order explicitly: winget appends its package
+    # directory to the user PATH, so the margin depends on nothing more than two
+    # append orders staying as they are, in a file Microsoft owns and revises.
+    # Putting the directory first costs nothing and removes the dependency.
+    #
+    # Ninja is worth this attention where a one-off tool would not be: it is
+    # re-invoked for every edge in the build graph, so it is the one place an
+    # emulated binary is paid over and over rather than once.
+    Write-Step 'Native ninja ahead of the Visual Studio copy'
+
+    $candidates = @()
+    $candidates += Get-ChildItem (Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages') `
+                       -Filter 'ninja.exe' -Recurse -Depth 2 -ErrorAction SilentlyContinue |
+                   ForEach-Object { $_.FullName }
+    $candidates += @(
+        (Join-Path $env:ProgramFiles 'Ninja\ninja.exe')
+        (Join-Path $env:LOCALAPPDATA 'Programs\Ninja\ninja.exe')
+    )
+    # A ninja already on the PATH counts too - but only if it is not the VS one,
+    # which is the binary this step exists to get out in front of.
+    $onPath = (Get-Command ninja.exe -ErrorAction SilentlyContinue | Select-Object -First 1).Source
+    if ($onPath -and $onPath -notmatch 'CommonExtensions\\Microsoft\\CMake') { $candidates += $onPath }
+
+    $native = $null
+    foreach ($c in ($candidates | Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique)) {
+        $m = Get-PEMachine $c
+        if ($m -eq $(if ($IsArm64) { 'ARM64' } else { 'x64' })) { $native = $c; break }
+    }
+
+    if (-not $native) {
+        Write-Warning 'No native ninja.exe found. Install it with: winget install Ninja-build.Ninja'
+        Write-Warning 'Until then a build using the Ninja generator gets the x64 ninja Visual Studio bundles.'
+        return
+    }
+
+    Write-Host "    native ninja: $native ($(Get-PEMachine $native))"
+    Add-ToUserPath (Split-Path $native -Parent) -Prepend
+}
+
 function Get-PEMachine {
     # Architecture of a PE, read straight from the COFF header: the 2 bytes at
     # the e_lfanew offset + 4. Cheap, and it answers the only question that
@@ -386,6 +466,31 @@ function Invoke-ArchAudit {
     # either native or a known, listed exception.
     Write-Step "Architecture audit (host: $HostArch)"
 
+    # Resolve against the PATH a NEW shell would get, not this process's.
+    #
+    # The steps above write the HKCU PATH, which a running process never sees -
+    # so auditing $env:PATH would report the state from before this script ran and
+    # warn about a problem it had just fixed. Compose machine + user from the
+    # registry (the order Windows itself uses), then append anything extra this
+    # process happens to carry: that tail is where a Developer Command Prompt's VS
+    # directories live, and keeping it last mirrors how VsDevCmd appends them.
+    $composed = @()
+    foreach ($scope in 'Machine', 'User') {
+        $v = [Environment]::GetEnvironmentVariable('Path', $scope)
+        if ($v) { $composed += ($v -split ';' | Where-Object { $_.Trim() }) }
+    }
+    $composed += ($env:PATH -split ';' | Where-Object { $_.Trim() })
+    $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
+    $searchPath = @($composed | Where-Object { $seen.Add($_.Trim().TrimEnd('\')) })
+
+    function Resolve-OnPath([string]$Exe) {
+        foreach ($d in $searchPath) {
+            $p = Join-Path $d $Exe
+            if (Test-Path $p -PathType Leaf) { return $p }
+        }
+        return $null
+    }
+
     # Tools with no native ARM64 build available anywhere, with the reason. These
     # print as expected rather than as problems - see the README's ARM64 section.
     $KnownEmulated = @{
@@ -402,14 +507,12 @@ function Invoke-ArchAudit {
     # it. Printed with the finding so the log carries the remedy, not just the
     # complaint.
     #
-    # ninja is the one that actually bites. Visual Studio bundles an x64 ninja.exe
-    # even on ARM64, and VsDevCmd.bat PREPENDS the VS directories to PATH - so
-    # inside a Developer Command Prompt the emulated one wins over the native
-    # winget copy, and that is exactly the shell C++ builds happen in. It matters
-    # more than a one-off tool because ninja is re-invoked for every edge in the
-    # build graph.
+    # ninja is the one that actually bites: Visual Studio bundles an x64 ninja.exe
+    # even on ARM64, and it is re-invoked for every edge in the build graph, so
+    # an emulated one is paid over and over rather than once. The NinjaPath step
+    # puts the native copy first; this is the check that it worked.
     $Remedies = @{
-        'ninja.exe'    = 'Visual Studio bundles an x64 ninja and VsDevCmd prepends its directory. Pass -DCMAKE_MAKE_PROGRAM to the native one (winget install Ninja-build.Ninja), or put its directory ahead of the VS one.'
+        'ninja.exe'    = 'Visual Studio bundles an x64 ninja. Install the native one (winget install Ninja-build.Ninja) and re-run - the NinjaPath step puts its directory at the front of the user PATH.'
         'cmake.exe'    = 'Install the native build with: winget install Kitware.CMake'
         'clang-cl.exe' = 'Install the upstream native LLVM with: winget install LLVM.LLVM'
     }
@@ -459,8 +562,7 @@ function Invoke-ArchAudit {
         # PATH first - that is the binary a build would actually invoke - then the
         # explicit candidates. The bare exe name is stripped of any " (label)".
         $exe = ($name -split ' ')[0]
-        $path = (Get-Command $exe -ErrorAction SilentlyContinue |
-                 Select-Object -First 1).Source
+        $path = Resolve-OnPath $exe
         if (-not $path) { $path = $targets[$name] | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1 }
         if (-not $path) { Write-Host ("    {0,-22} {1}" -f $name, '- not installed') -ForegroundColor DarkGray; continue }
 
@@ -502,6 +604,7 @@ $steps = [ordered]@{
     VsWhere   = { Add-VsWhereToUserPath }
     BinSkim   = { Install-BinSkim }
     GitConfig = { Set-GlobalGitConfig }
+    NinjaPath = { Set-NativeNinjaFirst }
     # Last on purpose: it reports on what the steps above (and the winget installs
     # in setup-windows.bat) actually put on the box.
     ArchAudit = { Invoke-ArchAudit }
index c91262cd06ad3bae4d04a23cd409933f39ba0a4d..05e65eb822507a0f536a6d41a67a0e0cc5669eb4 100644 (file)
@@ -62,9 +62,13 @@ winget install WinMerge.WinMerge
 @rem     graph, which makes it the one tool here where emulation is actually paid\r
 @rem     over and over, and ninja-winarm64.zip is a native build. Worth having.\r
 @rem\r
-@rem Both are on the machine/user PATH after install, so a bare `cmake -G Ninja`\r
-@rem picks these up. Inside a VS Developer prompt the VS copies may come first;\r
-@rem that is fine for cmake (same architecture) and is the case to watch for ninja.\r
+@rem Which ninja wins inside a VS Developer Command Prompt: the native one. VS\r
+@rem adds its copy from Common7\Tools\vsdevcmd\ext\cmake.bat with\r
+@rem     set "PATH=%PATH%;...\CMake\bin;...\CMake\Ninja"\r
+@rem which APPENDS, so the VS directories sit at the very end of the composed\r
+@rem PATH, behind every machine and user entry. setup-windows-no-uac.ps1 then\r
+@rem pins the native ninja's directory to the FRONT of the user PATH so the\r
+@rem margin does not depend on two append orders staying as they are.\r
 winget install Kitware.CMake\r
 winget install Ninja-build.Ninja\r
 \r