Netscoot.Core/Public/Repair-SolutionReferences.ps1
|
function Repair-SolutionReferences { <# .SYNOPSIS Scan a repository for broken solution membership and dangling ProjectReferences and repair them by re-pointing each entry at the project's new location. .DESCRIPTION Finds solution entries and `<ProjectReference>`s that point at a project file which no longer exists at the recorded path (usually because a project was moved or renamed without reconciling). Read-only by default: It returns one object per problem, each tagged with a Resolution of Relocatable, Missing, or Ambiguous. With -Fix it repairs every Relocatable entry: It searches the repository for a project file of the same name and re-points the entry at it through the dotnet CLI (remove the stale path, add the found one). When one project of that name exists it is used directly. When several do, the one that keeps the most of the original path's trailing folders is chosen, since a moved project usually keeps its own folder name. Entries it cannot resolve are left untouched and reported, Missing (no such project anywhere) or Ambiguous (several equally-good candidates). A relocatable .vcxproj is reported with its new location but not re-pointed, because the dotnet CLI cannot load a native project. Re-point it in Visual Studio. With -Prune it removes the Missing entries, the genuinely deleted ones, through the dotnet CLI. -Prune never touches Relocatable or Ambiguous entries. -Fix and -Prune can be combined. .PARAMETER RepositoryRoot Root to scan. Accepts pipeline input: a path string, or a directory item from Get-Item. Defaults to the enclosing git repository root of the current directory. .PARAMETER Fix Re-point each dangling entry at the moved project when its new location is unambiguous. Honors -WhatIf. .PARAMETER Prune Remove entries whose project cannot be found anywhere in the repository. Honors -WhatIf. .OUTPUTS Netscoot.RepairResult - one per dangling entry. .EXAMPLE # Report dangling entries only - read-only (each tagged Relocatable, Missing, or Ambiguous) Repair-SolutionReferences -RepositoryRoot . # Re-point relocatable entries at the project's new location (relocates, never deletes) Repair-SolutionReferences -RepositoryRoot . -Fix # Also remove entries whose project is gone for good - preview the whole thing first Repair-SolutionReferences -RepositoryRoot . -Fix -Prune -WhatIf .LINK Get-SolutionInventory .LINK Test-SolutionConsistency .LINK Sync-Solution .LINK Find-PathReference #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] [OutputType('Netscoot.RepairResult')] param( [Parameter(Position = 0, ValueFromPipeline)] [Netscoot.PathInputTransform()] [string]$RepositoryRoot, [switch]$Fix, [switch]$Prune ) process { if (-not (Assert-DotnetAvailable -Cmdlet $PSCmdlet)) { return } if (-not $RepositoryRoot) { $RepositoryRoot = Get-RepositoryRoot -StartPath (Get-Location).Path } $RepositoryRoot = Resolve-FullPath $RepositoryRoot # One repository parse for this invocation: the project glob (for the leaf index and the # reference scan) and every solution come from the workspace, so no file is read twice. $workspace = Get-Workspace -RepositoryRoot $RepositoryRoot $managedProjects = @(Get-WorkspaceProjectFiles -Workspace $workspace) # Index existing project files (native included) by leaf name, so a dangling target can be # matched to where its project now lives. $byLeaf = @{} foreach ($pf in @(Get-WorkspaceProjectFiles -Workspace $workspace -IncludeNative)) { if (-not $byLeaf.ContainsKey($pf.Name)) { $byLeaf[$pf.Name] = [System.Collections.Generic.List[string]]::new() } $byLeaf[$pf.Name].Add($pf.FullName) } # First pass: collect the dangling entries (a path recorded somewhere points at a project # file that no longer exists there). $dangling = [System.Collections.Generic.List[object]]::new() foreach ($parsed in (Get-WorkspaceSolutions -Workspace $workspace)) { foreach ($entry in $parsed.Projects) { if ($entry.Stored -notmatch '\.(cs|fs|vb|vcx)proj$') { continue } if (-not (Test-Path -LiteralPath $entry.Abs)) { $dangling.Add([pscustomobject]@{ Kind = 'Solution'; Container = $parsed.FullName; Missing = $entry.Stored; MissingAbs = $entry.Abs }) } } } foreach ($proj in $managedProjects) { foreach ($ref in (Get-WorkspaceProjectRefs -Workspace $workspace -ProjectFile $proj.FullName)) { # Non-literal references (MSBuild property / glob / conditional) have no single # resolved path, so they cannot be "dangling" in a way we could repair - skip them. if (-not $ref.IsLiteral) { continue } if (-not (Test-Path -LiteralPath $ref.FullPath)) { $dangling.Add([pscustomobject]@{ Kind = 'Reference'; Container = $proj.FullName; Missing = $ref.Raw; MissingAbs = $ref.FullPath }) } } } # Second pass: classify each by where (if anywhere) its project now lives. $problems = [System.Collections.Generic.List[object]]::new() foreach ($d in $dangling) { $leaf = Split-Path -Leaf $d.MissingAbs $cands = @() if ($byLeaf.ContainsKey($leaf)) { $cands = @($byLeaf[$leaf]) } $n = $cands.Count if ($n -eq 0) { $resolution = 'Missing'; $newPath = $null } elseif ($n -eq 1) { $resolution = 'Relocatable'; $newPath = $cands[0] } else { # Several projects share this leaf name. Disambiguate by which candidate keeps the # most of the original path's trailing folders (a moved project usually keeps its # own folder name); only auto-resolve when that best match is unique. $best = Select-BestSuffixMatch -Original $d.MissingAbs -Candidates $cands if ($best) { $resolution = 'Relocatable'; $newPath = $best } else { $resolution = 'Ambiguous'; $newPath = $null } } $problems.Add([pscustomobject]@{ PSTypeName = 'Netscoot.RepairResult' Kind = $d.Kind Resolution = $resolution Missing = $d.Missing NewPath = $newPath Container = $d.Container MissingAbs = $d.MissingAbs Candidates = $cands }) } if ($problems.Count -eq 0) { Write-Host 'No dangling solution entries or project references found.' -ForegroundColor Green return } Write-Host "Found $($problems.Count) dangling entr$(if ($problems.Count -eq 1) { 'y' } else { 'ies' }):" -ForegroundColor Yellow ($problems | Format-Table Kind, Resolution, Missing, Container -AutoSize | Out-String) | Write-Host if (-not ($Fix -or $Prune)) { Write-Host 'Run with -Fix to re-point movable entries, or -Prune to remove ones whose project is gone.' -ForegroundColor Cyan return $problems } foreach ($p in $problems) { if ($Fix -and $p.Resolution -eq 'Relocatable' -and (Test-IsNativeProject $p.NewPath)) { # The dotnet CLI cannot load a .vcxproj to re-add it. Write-Warning "$($p.Missing) moved to $($p.NewPath). Re-point it in Visual Studio (a native project cannot be re-added through the dotnet CLI)." } elseif ($Fix -and $p.Resolution -eq 'Relocatable') { if ($p.Kind -eq 'Solution') { if ($PSCmdlet.ShouldProcess($p.Container, "re-point $($p.Missing) -> $($p.NewPath)")) { Invoke-Dotnet sln $p.Container remove $p.MissingAbs Invoke-Dotnet sln $p.Container add $p.NewPath } } else { if ($PSCmdlet.ShouldProcess($p.Container, "re-point reference $($p.Missing) -> $($p.NewPath)")) { Invoke-Dotnet remove $p.Container reference $p.MissingAbs Invoke-Dotnet add $p.Container reference $p.NewPath } } } elseif ($Prune -and $p.Resolution -eq 'Missing') { if ($p.Kind -eq 'Solution') { if ($PSCmdlet.ShouldProcess($p.Container, "remove gone entry $($p.Missing)")) { Invoke-Dotnet sln $p.Container remove $p.MissingAbs } } else { if ($PSCmdlet.ShouldProcess($p.Container, "remove gone reference $($p.Missing)")) { Invoke-Dotnet remove $p.Container reference $p.MissingAbs } } } else { $why = switch ($p.Resolution) { 'Relocatable' { 'movable, run with -Fix' } 'Missing' { 'project not found, run with -Prune to remove' } 'Ambiguous' { 'more than one candidate, resolve by hand' } } Write-Host " skipped [$($p.Kind)] $($p.Missing): $why" -ForegroundColor DarkYellow } } return $problems } } |