Public/Remove-StaleDotNetRuntime.ps1

function Remove-StaleDotNetRuntime {
    <#
    .SYNOPSIS
        Removes superseded .NET shared framework (runtime) versions.
 
    .DESCRIPTION
        Inspects the installed .NET shared frameworks (Microsoft.NETCore.App,
        Microsoft.AspNetCore.App, Microsoft.WindowsDesktop.App, and any other
        framework found under the dotnet 'shared' folder) and removes every
        version that has been superseded by a newer patch within the same
        major.minor band. The newest version of each band is always kept.
 
        Prerelease versions are ranked below the matching stable release, so a
        stable 8.0.11 supersedes 8.0.11-preview.1.
 
        Removing a shared framework requires an elevated session. Supports
        -WhatIf and -Confirm; by default each removal must be confirmed.
 
    .PARAMETER Path
        One or more shared framework directories to evaluate (the folder that
        contains the version subdirectories). Defaults to every framework found
        under the 64-bit and 32-bit dotnet installations.
 
    .PARAMETER Band
        Limits processing to the specified major.minor bands, for example
        '6.0' or '8.0'. By default all bands are evaluated.
 
    .PARAMETER KeepVersions
        The number of most recent versions to keep in each major.minor band.
        Defaults to 1.
 
    .EXAMPLE
        Remove-StaleDotNetRuntime -WhatIf
 
        Shows which superseded shared framework versions would be removed.
 
    .EXAMPLE
        Remove-StaleDotNetRuntime -Confirm:$false
 
        Removes every superseded shared framework version without prompting.
 
    .EXAMPLE
        Remove-StaleDotNetRuntime -Band '6.0', '8.0' -KeepVersions 2
 
        Keeps the two newest patches of the 6.0 and 8.0 bands and removes the rest.
 
    .OUTPUTS
        System.IO.DirectoryInfo
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType([System.IO.DirectoryInfo])]
    param(
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('FullName')]
        [string[]]$Path,

        [ValidatePattern('^\d+\.\d+$')]
        [string[]]$Band,

        [ValidateRange(1, 1000)]
        [int]$KeepVersions = 1
    )

    begin {
        $versionRegex = [regex]::new('^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?:-(?<pre>[0-9A-Za-z.-]+))?$')
        $removed = $false

        function Test-Elevated {
            $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
            $principal = [Security.Principal.WindowsPrincipal]::new($identity)
            $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
        }

        function Get-DefaultSharedPath {
            $roots = @($env:ProgramFiles, ${env:ProgramFiles(x86)}) |
                Where-Object { $_ } |
                Select-Object -Unique |
                ForEach-Object { Join-Path $_ 'dotnet\shared' }

            foreach ($root in $roots) {
                if (Test-Path -LiteralPath $root -PathType Container) {
                    (Get-ChildItem -LiteralPath $root -Directory).FullName
                }
            }
        }

        if (-not $WhatIfPreference -and -not (Test-Elevated)) {
            Write-Warning 'Not running elevated; removing a .NET shared framework requires administrator rights and will likely fail with access denied.'
        }

        $resolvedPaths = [System.Collections.Generic.List[string]]::new()
    }

    process {
        foreach ($item in $Path) {
            if ($item) { $resolvedPaths.Add($item) }
        }
    }

    end {
        if ($resolvedPaths.Count -eq 0) {
            $discovered = Get-DefaultSharedPath
            foreach ($item in $discovered) { $resolvedPaths.Add($item) }
        }

        if ($resolvedPaths.Count -eq 0) {
            Write-Warning 'No .NET shared framework directories were found.'
            return
        }

        $failures = [System.Collections.Generic.List[string]]::new()

        foreach ($frameworkPath in ($resolvedPaths | Select-Object -Unique)) {
            if (-not (Test-Path -LiteralPath $frameworkPath -PathType Container)) {
                Write-Verbose "Skipping missing framework directory: $frameworkPath"
                continue
            }

            $versions = Get-ChildItem -LiteralPath $frameworkPath -Directory |
                ForEach-Object {
                    $match = $versionRegex.Match($_.Name)
                    if (-not $match.Success) {
                        Write-Verbose "Ignoring non-version directory: $($_.FullName)"
                        return
                    }

                    [PSCustomObject]@{
                        Directory  = $_
                        Band       = '{0}.{1}' -f $match.Groups['major'].Value, $match.Groups['minor'].Value
                        Version    = [version]('{0}.{1}.{2}' -f $match.Groups['major'].Value, $match.Groups['minor'].Value, $match.Groups['patch'].Value)
                        IsStable   = -not $match.Groups['pre'].Success
                        Prerelease = $match.Groups['pre'].Value
                    }
                }

            if (-not $versions) {
                Write-Verbose "No installed versions found in: $frameworkPath"
                continue
            }

            foreach ($group in ($versions | Group-Object Band)) {
                if ($Band -and $group.Name -notin $Band) {
                    continue
                }

                $ordered = $group.Group | Sort-Object Version, IsStable, Prerelease -Descending
                $keep = $ordered | Select-Object -First $KeepVersions
                $stale = $ordered | Select-Object -Skip $KeepVersions

                Write-Verbose "$frameworkPath [$($group.Name)]: keeping $(($keep.Directory.Name) -join ', ')"

                foreach ($candidate in $stale) {
                    $target = $candidate.Directory.FullName
                    if (-not $PSCmdlet.ShouldProcess($target, 'Remove .NET shared framework version')) {
                        continue
                    }

                    try {
                        Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction Stop
                        Write-Verbose "Deleted: $target"
                        $removed = $true
                        $candidate.Directory
                    }
                    catch {
                        $failures.Add("${target}: $($_.Exception.Message)")
                    }
                }
            }
        }

        if (-not $removed) {
            Write-Verbose 'No superseded .NET shared framework versions were removed.'
        }

        if ($failures.Count -gt 0) {
            $noun = if ($failures.Count -eq 1) { 'version' } else { 'versions' }
            throw "Failed to delete $($failures.Count) .NET shared framework ${noun}:`n$($failures -join "`n")"
        }
    }
}