diff --git a/docs/customization.md b/docs/customization.md index cd09f9c2..9a8080bc 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -225,6 +225,34 @@ so the noise around it is turned off rather than printed twice: are skipped whenever the fastfetch banner rendered. On a light install without fastfetch they stay, so an interactive shell still confirms the profile loaded. +## Projects folder and Dev Drive + +Interactive shells `cd` into your projects folder at startup (unless the shell +was launched from VS Code, or you're already somewhere under a path containing +`projects`). + +On Windows the folder is resolved by `Get-ProjectsPath` in the order below, so a +[Dev Drive](https://learn.microsoft.com/en-us/windows/dev-drive/) — a ReFS +volume tuned for developer workloads — wins over the user profile: + +1. `$env:PROJECTS_PATH`, when set. +2. `\projects`, when a Dev Drive has one (for example `D:\projects`). +3. `%USERPROFILE%\projects`. + +`run_once_before_00-setup.ps1` creates the folder on the Dev Drive when one is +present, so a fresh machine with a Dev Drive gets `D:\projects` instead of +`%USERPROFILE%\projects`. + +!!! note "How the Dev Drive is detected" + `fsutil devdrv query` is the authoritative check, but it needs an elevated + shell — unusable from a profile. Fixed, ready ReFS volumes are used as the + heuristic instead. If that guesses wrong (a plain ReFS data volume, or + several Dev Drives), pin the right one with `$env:DEV_DRIVE`, or skip + detection entirely with `$env:PROJECTS_PATH`. + +The Linux/macOS shell configs (`fish`, `bash`, `zsh`) always use +`$HOME/projects`; Dev Drive is a Windows-only feature. + ## Windows PATH On Windows, the setup adds `%OneDrive%\Portable Programs` to the user-scope diff --git a/docs/installation.md b/docs/installation.md index 66a758b9..dfaa6723 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -61,6 +61,12 @@ the dotfiles. cd dotfiles ``` + !!! tip "Using a Dev Drive" + If you have a [Dev Drive](https://learn.microsoft.com/en-us/windows/dev-drive/), + clone into `\projects` instead (for example `D:\projects`) — + that's the folder the PowerShell profile will `cd` into afterwards. See + [Projects folder and Dev Drive](customization.md#projects-folder-and-dev-drive). + To use SSH afterwards (e.g. with the 1Password SSH agent), install 1Password and enable the agent, then switch the remote to SSH: diff --git a/home/.chezmoiscripts/windows/run_once_before_00-setup.ps1 b/home/.chezmoiscripts/windows/run_once_before_00-setup.ps1 index 04623d0b..557ad162 100644 --- a/home/.chezmoiscripts/windows/run_once_before_00-setup.ps1 +++ b/home/.chezmoiscripts/windows/run_once_before_00-setup.ps1 @@ -5,10 +5,34 @@ $ErrorActionPreference = "Stop" Write-Host ">> Running initial Windows setup..." -ForegroundColor Cyan +# Prefer a Dev Drive (fixed ReFS volume) over the user profile for source code. +# "fsutil devdrv query" needs elevation, so fixed ReFS volumes are the heuristic. +# Set DEV_DRIVE to pin a specific volume. +$devDriveRoot = $null + +if (-not [string]::IsNullOrWhiteSpace($env:DEV_DRIVE) -and (Test-Path -LiteralPath $env:DEV_DRIVE.Trim().Trim('"'))) { + $devDriveRoot = $env:DEV_DRIVE.Trim().Trim('"') +} +else { + $devDriveRoot = [System.IO.DriveInfo]::GetDrives() | + Where-Object { $_.IsReady -and $_.DriveType -eq [System.IO.DriveType]::Fixed -and $_.DriveFormat -eq 'ReFS' } | + Sort-Object Name | + Select-Object -First 1 -ExpandProperty RootDirectory | + Select-Object -ExpandProperty FullName +} + +$projectsPath = if ($devDriveRoot) { + Write-Host "Dev Drive detected: $devDriveRoot" -ForegroundColor Cyan + Join-Path $devDriveRoot "projects" +} +else { + Join-Path $env:USERPROFILE "projects" +} + # Create necessary directories $directories = @( "$env:USERPROFILE\bin", - "$env:USERPROFILE\projects" + $projectsPath ) foreach ($dir in $directories) { diff --git a/home/dot_config/powershell/modules/DotfilesHelpers/DotfilesHelpers.psd1 b/home/dot_config/powershell/modules/DotfilesHelpers/DotfilesHelpers.psd1 index 0e9a7d98..19f56136 100644 --- a/home/dot_config/powershell/modules/DotfilesHelpers/DotfilesHelpers.psd1 +++ b/home/dot_config/powershell/modules/DotfilesHelpers/DotfilesHelpers.psd1 @@ -15,6 +15,11 @@ 'Set-LocationUp' 'Set-LocationUpUp' + # Dev Drive / projects folder + 'Test-DevDriveSupported' + 'Get-DevDrivePath' + 'Get-ProjectsPath' + # System utilities 'which' 'touch' diff --git a/home/dot_config/powershell/modules/DotfilesHelpers/Public/DevDrive.ps1 b/home/dot_config/powershell/modules/DotfilesHelpers/Public/DevDrive.ps1 new file mode 100644 index 00000000..5472a675 --- /dev/null +++ b/home/dot_config/powershell/modules/DotfilesHelpers/Public/DevDrive.ps1 @@ -0,0 +1,144 @@ +# Dev Drive detection and projects folder resolution +# +# A Dev Drive is a Windows 11 ReFS volume tuned for developer workloads. It is +# a much better home for source code than the user profile (which is usually on +# the NTFS system drive and covered by real-time antivirus scanning). +# +# Note: "fsutil devdrv query" is the authoritative check, but it requires an +# elevated shell, so it can't be used from a profile. Fixed ReFS volumes are +# used as the heuristic instead - on a normal workstation those are Dev Drives. +# Set $env:DEV_DRIVE to pin a specific volume, or $env:PROJECTS_PATH to pin the +# projects folder outright. + +function Test-DevDriveSupported { + <# + .SYNOPSIS + Returns $true when running on Windows, where Dev Drives exist. + #> + [CmdletBinding()] + [OutputType([bool])] + param() + + if ($PSVersionTable.PSEdition -eq 'Desktop') { + return $true + } + + return [bool](Get-Variable -Name IsWindows -ValueOnly -ErrorAction SilentlyContinue) +} + +function Get-DevDrivePath { + <# + .SYNOPSIS + Returns the root paths of the Dev Drives on this machine. + + .DESCRIPTION + Honours $env:DEV_DRIVE when set (and the path exists). Otherwise returns + every fixed, ready ReFS volume, sorted by drive letter. Returns an empty + array when no Dev Drive is present or the platform isn't Windows. + + .EXAMPLE + Get-DevDrivePath + D:\ + #> + [CmdletBinding()] + [OutputType([string[]])] + param() + + if (-not [string]::IsNullOrWhiteSpace($env:DEV_DRIVE)) { + $override = $env:DEV_DRIVE.Trim().Trim('"') + if (Test-Path -LiteralPath $override) { + return @($override) + } + + Write-Verbose "DEV_DRIVE is set to '$override' but that path does not exist; ignoring it." + } + + if (-not (Test-DevDriveSupported)) { + return @() + } + + $roots = @() + + try { + foreach ($drive in [System.IO.DriveInfo]::GetDrives()) { + if (-not $drive.IsReady) { continue } + if ($drive.DriveType -ne [System.IO.DriveType]::Fixed) { continue } + if ($drive.DriveFormat -ne 'ReFS') { continue } + + $roots += $drive.RootDirectory.FullName + } + } + catch { + Write-Verbose "Failed to enumerate drives: $_" + return @() + } + + return @($roots | Sort-Object) +} + +function Get-ProjectsPath { + <# + .SYNOPSIS + Resolves the folder that holds local source code checkouts. + + .DESCRIPTION + Resolution order: + 1. $env:PROJECTS_PATH, when set. + 2. \projects, when a Dev Drive has one. + 3. $env:USERPROFILE\projects (or $HOME/projects off Windows). + + Without -CreateIfMissing the function never touches the file system, so + it is safe to call from the PowerShell profile on every startup. + + .PARAMETER CreateIfMissing + Create the resolved folder when it does not exist yet. A Dev Drive is + preferred over the user profile when creating. + + .EXAMPLE + Get-ProjectsPath + D:\projects + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([string])] + param( + [switch]$CreateIfMissing + ) + + $userHome = if ($env:USERPROFILE) { $env:USERPROFILE } else { $HOME } + $fallback = Join-Path $userHome 'projects' + + if (-not [string]::IsNullOrWhiteSpace($env:PROJECTS_PATH)) { + $explicit = $env:PROJECTS_PATH.Trim().Trim('"') + + if ($CreateIfMissing -and -not (Test-Path -LiteralPath $explicit)) { + if ($PSCmdlet.ShouldProcess($explicit, 'Create projects directory')) { + New-Item -ItemType Directory -Path $explicit -Force | Out-Null + } + } + + return $explicit + } + + $devDrives = @(Get-DevDrivePath) + + foreach ($root in $devDrives) { + $candidate = Join-Path $root 'projects' + if (Test-Path -LiteralPath $candidate) { + return $candidate + } + } + + if ($CreateIfMissing) { + $target = if ($devDrives.Count -gt 0) { Join-Path $devDrives[0] 'projects' } else { $fallback } + + if (-not (Test-Path -LiteralPath $target)) { + if ($PSCmdlet.ShouldProcess($target, 'Create projects directory')) { + New-Item -ItemType Directory -Path $target -Force | Out-Null + } + } + + return $target + } + + return $fallback +} diff --git a/home/dot_config/powershell/profile.ps1 b/home/dot_config/powershell/profile.ps1 index 0493b13d..1b58e806 100644 --- a/home/dot_config/powershell/profile.ps1 +++ b/home/dot_config/powershell/profile.ps1 @@ -51,11 +51,23 @@ if ($env:OneDrive) { } } +# Load DotfilesHelpers module (lazy-loadable via PSModulePath, explicit import for profile) +$dotfilesModulePath = Join-Path $PSScriptRoot "modules\DotfilesHelpers" +if (Test-Path $dotfilesModulePath) { + Import-Module $dotfilesModulePath -DisableNameChecking +} + # Set working directory to projects folder if not already there +# Get-ProjectsPath prefers a Dev Drive over $env:USERPROFILE when one exists # Skip this if running in VS Code to preserve the opened folder location if ($ENV:TERM_PROGRAM -ne "vscode") { $currentPath = (Get-Location).Path - $projectsPath = Join-Path $env:USERPROFILE "projects" + $projectsPath = if (Get-Command Get-ProjectsPath -ErrorAction SilentlyContinue) { + Get-ProjectsPath + } + else { + Join-Path $env:USERPROFILE "projects" + } # Check if current path contains 'projects' (case-insensitive) if ($currentPath -notlike "*projects*") { @@ -66,12 +78,6 @@ if ($ENV:TERM_PROGRAM -ne "vscode") { } } -# Load DotfilesHelpers module (lazy-loadable via PSModulePath, explicit import for profile) -$dotfilesModulePath = Join-Path $PSScriptRoot "modules\DotfilesHelpers" -if (Test-Path $dotfilesModulePath) { - Import-Module $dotfilesModulePath -DisableNameChecking -} - # Load aliases . $PSScriptRoot\aliases.ps1 diff --git a/tests/powershell/DevDrive.Tests.ps1 b/tests/powershell/DevDrive.Tests.ps1 new file mode 100644 index 00000000..e7cf0f63 --- /dev/null +++ b/tests/powershell/DevDrive.Tests.ps1 @@ -0,0 +1,143 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Pester tests for Dev Drive detection in the DotfilesHelpers module. + +.DESCRIPTION + Tests Get-DevDrivePath and Get-ProjectsPath - detection of a Dev Drive + (fixed ReFS volume) and resolution of the projects folder, including the + DEV_DRIVE / PROJECTS_PATH overrides and the user profile fallback. +#> + +BeforeAll { + $script:RepoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + Push-Location $script:RepoRoot + + $modulePath = Join-Path $script:RepoRoot "home/dot_config/powershell/modules/DotfilesHelpers" + Get-Module DotfilesHelpers -All | Remove-Module -Force -ErrorAction SilentlyContinue + Import-Module $modulePath -Force -DisableNameChecking + + $script:OriginalDevDrive = $env:DEV_DRIVE + $script:OriginalProjectsPath = $env:PROJECTS_PATH + + $tmpRoot = if ($env:TEMP) { $env:TEMP } else { '/tmp' } + $script:TestRoot = (New-Item -ItemType Directory -Path (Join-Path $tmpRoot "devdrive-tests-$(Get-Random)") -Force).FullName +} + +AfterAll { + $env:DEV_DRIVE = $script:OriginalDevDrive + $env:PROJECTS_PATH = $script:OriginalProjectsPath + + Pop-Location + if (Test-Path $script:TestRoot) { + Remove-Item -Recurse -Force $script:TestRoot -ErrorAction SilentlyContinue + } +} + +Describe "Get-DevDrivePath Function" -Tag "Unit" { + BeforeEach { + $env:DEV_DRIVE = $null + $env:PROJECTS_PATH = $null + } + + It "Should be available as a function" { + Get-Command Get-DevDrivePath -CommandType Function -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty + } + + It "Should not throw" { + { Get-DevDrivePath } | Should -Not -Throw + } + + It "Should return existing paths only" { + foreach ($path in @(Get-DevDrivePath)) { + Test-Path -LiteralPath $path | Should -BeTrue + } + } + + It "Should honour DEV_DRIVE when the path exists" { + $env:DEV_DRIVE = $script:TestRoot + + @(Get-DevDrivePath) | Should -Be @($script:TestRoot) + } + + It "Should ignore DEV_DRIVE when the path does not exist" { + $env:DEV_DRIVE = Join-Path $script:TestRoot "does-not-exist" + + @(Get-DevDrivePath) | Should -Not -Contain $env:DEV_DRIVE + } +} + +Describe "Get-ProjectsPath Function" -Tag "Unit" { + BeforeEach { + $env:DEV_DRIVE = $null + $env:PROJECTS_PATH = $null + } + + It "Should be available as a function" { + Get-Command Get-ProjectsPath -CommandType Function -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty + } + + It "Should honour PROJECTS_PATH above everything else" { + $env:DEV_DRIVE = $script:TestRoot + $env:PROJECTS_PATH = Join-Path $script:TestRoot "explicit" + + Get-ProjectsPath | Should -Be $env:PROJECTS_PATH + } + + It "Should return the Dev Drive projects folder when it exists" { + $env:DEV_DRIVE = $script:TestRoot + $expected = Join-Path $script:TestRoot "projects" + New-Item -ItemType Directory -Path $expected -Force | Out-Null + + try { + Get-ProjectsPath | Should -Be $expected + } + finally { + Remove-Item -Recurse -Force $expected -ErrorAction SilentlyContinue + } + } + + It "Should fall back to the user profile when the Dev Drive has no projects folder" { + $env:DEV_DRIVE = $script:TestRoot + $userHome = if ($env:USERPROFILE) { $env:USERPROFILE } else { $HOME } + + Get-ProjectsPath | Should -Be (Join-Path $userHome "projects") + } + + It "Should not create anything without -CreateIfMissing" { + $env:DEV_DRIVE = $script:TestRoot + + Get-ProjectsPath | Out-Null + + Test-Path -LiteralPath (Join-Path $script:TestRoot "projects") | Should -BeFalse + } + + It "Should create the Dev Drive projects folder with -CreateIfMissing" { + $env:DEV_DRIVE = $script:TestRoot + $expected = Join-Path $script:TestRoot "projects" + + try { + Get-ProjectsPath -CreateIfMissing | Should -Be $expected + Test-Path -LiteralPath $expected | Should -BeTrue + } + finally { + Remove-Item -Recurse -Force $expected -ErrorAction SilentlyContinue + } + } +} + +Describe "DevDrive module exports" -Tag "Unit" { + BeforeAll { + $script:ManifestPath = Join-Path $script:RepoRoot "home/dot_config/powershell/modules/DotfilesHelpers/DotfilesHelpers.psd1" + $script:Manifest = Import-PowerShellDataFile -Path $script:ManifestPath + } + + It "Should export <_> from the module manifest" -ForEach @('Test-DevDriveSupported', 'Get-DevDrivePath', 'Get-ProjectsPath') { + $script:Manifest.FunctionsToExport | Should -Contain $_ + } + + It "Should not contain non-ASCII characters" { + $content = Get-Content (Join-Path $script:RepoRoot "home/dot_config/powershell/modules/DotfilesHelpers/Public/DevDrive.ps1") -Raw + $content | Should -Not -Match '[^\x00-\x7F]' + } +} diff --git a/tests/powershell/Profile.Tests.ps1 b/tests/powershell/Profile.Tests.ps1 index 857aa85c..a5870b46 100644 --- a/tests/powershell/Profile.Tests.ps1 +++ b/tests/powershell/Profile.Tests.ps1 @@ -216,11 +216,21 @@ Describe "Profile Configuration" { } It "Profile should set location to projects folder" { - # Verify the profile constructs projects path and changes to it + # Verify the profile resolves the projects path and changes to it + $script:ProfileContent | Should -Match 'Get-ProjectsPath' $script:ProfileContent | Should -Match 'Join-Path.*USERPROFILE.*projects' $script:ProfileContent | Should -Match 'Set-Location.*projectsPath' } + It "Profile should import DotfilesHelpers before resolving the projects path" { + # Get-ProjectsPath comes from the module, so the import has to happen first + $importIndex = $script:ProfileContent.IndexOf('Import-Module $dotfilesModulePath') + $projectsIndex = $script:ProfileContent.IndexOf('Get-ProjectsPath') + + $importIndex | Should -BeGreaterThan -1 + $projectsIndex | Should -BeGreaterThan $importIndex + } + It "Profile should verify projects folder exists before changing" { # Verify the profile tests for directory existence $script:ProfileContent | Should -Match 'Test-Path.*projectsPath'