Netscoot.Core/Public/Move-DotnetProject.ps1

function Move-DotnetProject {
    <#
    .SYNOPSIS
        Move a .NET project folder and reconcile every solution and project reference
        that points at it, delegating all path/GUID changes to the dotnet CLI.
 
    .DESCRIPTION
        Enumerates the solutions that include the project, the projects that reference it,
        and the project's own references. Removes those links while the old paths still
        resolve, moves the directory (git mv when tracked), then re-adds every link so the
        dotnet CLI recomputes fresh relative paths and preserves GUIDs. The solution and
        project XML (.sln/.slnx, .csproj) is never hand-edited.
 
        Diagnostics follow invocation: -Verbose narrates the plan, -Debug emits the full
        solution-membership matrix, and divergence (the project living in some but not all
        of the repository's solutions) is surfaced as a Warning (or, with -Strict, a non-
        terminating error honoring -ErrorAction).
 
    .PARAMETER Project
        Path to the project file (.csproj/.fsproj/.vbproj). Accepts pipeline input - pipe a
        path string or any object with a FullName/Path property (e.g. Get-Item output).
 
    .PARAMETER Destination
        Where to move the project folder, following `git mv` rules: if Destination is an existing
        directory the folder moves into it (keeping its name, e.g. './libs' -> './libs/Tarragon');
        otherwise Destination is the project's new folder path (a rename, './libs/Tarragon'). The
        project file and its sibling contents move as one.
 
    .PARAMETER RepositoryRoot
        Root to scan for solutions/consumers. Defaults to the enclosing git repository root.
 
    .PARAMETER Strict
        Escalate solution-divergence warnings to non-terminating errors.
 
    .PARAMETER NoBuild
        Skip the verifying 'dotnet build' at the end.
 
    .PARAMETER Force
        Proceed with a plain file move when git is unavailable instead of aborting. The plain move is a PowerShell `Move-Item` (same on every platform) and does not preserve git history.
 
    .PARAMETER NoJournal
        Skip recording this move in the undo journal for this call, even when journaling is enabled
        (Undo-Netscoot will not see this move).
 
    .OUTPUTS
        Netscoot.MoveResult
 
    .EXAMPLE
        # Preview the move and emit the plan object; nothing changes
        Move-DotnetProject -Project ./src/Tarragon/Tarragon.csproj -Destination ./libs/Tarragon -WhatIf
        # Rename the project folder src/Tarragon -> libs/Tarragon
        Move-DotnetProject -Project ./src/Tarragon/Tarragon.csproj -Destination ./libs/Tarragon
        # Destination is an existing folder -> moves into it, landing at libs/Tarragon
        Move-DotnetProject -Project ./src/Tarragon/Tarragon.csproj -Destination ./libs
        # Skip the verifying 'dotnet build' at the end
        Move-DotnetProject -Project ./src/Tarragon/Tarragon.csproj -Destination ./libs/Tarragon -NoBuild
        # Treat solution-membership divergence as a non-terminating error, not a warning
        Move-DotnetProject -Project ./src/Tarragon/Tarragon.csproj -Destination ./libs/Tarragon -Strict
        # Take the project from the pipeline
        Get-Item ./src/Tarragon/Tarragon.csproj | Move-DotnetProject -Destination ./libs/Tarragon
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType('Netscoot.MoveResult')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('FullName', 'Path', 'PSPath')]
        [ValidateNotNullOrEmpty()]
        [string]$Project,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Destination,

        [string]$RepositoryRoot,
        [switch]$Strict,
        [switch]$NoBuild,
        [switch]$Force,
        [switch]$NoJournal
    )

    process {
        if (-not (Assert-DotnetAvailable -Cmdlet $PSCmdlet)) { return }

        $projFull = Resolve-FullPath $Project
        if (-not (Test-Path -LiteralPath $projFull)) {
            $PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new(
                    [System.IO.FileNotFoundException]::new("Project not found: $Project"),
                    'ProjectNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $Project))
            return
        }
        if (Test-IsNativeProject $projFull) {
            $PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new(
                    [System.NotSupportedException]::new("'$Project' is a native (.vcxproj) project. Moving it safely requires reconciling AdditionalLibraryDirectories/AdditionalDependencies, <Import> of .props, and .vcxproj.filters, which the dotnet CLI cannot do. Use Move-NativeProject (Windows-only)."),
                    'NativeProjectNotSupported', [System.Management.Automation.ErrorCategory]::NotImplemented, $Project))
            return
        }
        if ($projFull -notmatch '\.(cs|fs|vb)proj$') {
            $PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new(
                    [System.ArgumentException]::new("Not a managed project file: $Project"),
                    'NotAProject', [System.Management.Automation.ErrorCategory]::InvalidArgument, $Project))
            return
        }

        $oldDir = Split-Path -Parent $projFull
        $projFile = Split-Path -Leaf $projFull
        if (-not $RepositoryRoot) { $RepositoryRoot = Get-RepositoryRoot -StartPath $oldDir }
        $repoFull = Resolve-FullPath $RepositoryRoot

        # git mv semantics: an existing destination directory means "move the project folder into
        # it" (libs -> libs/Tarragon); otherwise Destination is the project's new folder path.
        $newDir = Resolve-MoveTarget -Source $oldDir -Destination $Destination
        $newProj = Join-Path $newDir $projFile
        if (Test-Path -LiteralPath $newDir) {
            $PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new(
                    [System.IO.IOException]::new("Destination already exists: $newDir"),
                    'DestinationExists', [System.Management.Automation.ErrorCategory]::ResourceExists, $newDir))
            return
        }
        if (Test-PathOverlap $newDir $oldDir) {
            $PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new(
                    [System.InvalidOperationException]::new("Destination '$newDir' overlaps the source '$oldDir'; a project folder cannot be moved into itself or its own subtree."),
                    'PathOverlap', [System.Management.Automation.ErrorCategory]::InvalidArgument, $Destination))
            return
        }

        Write-Verbose "Scanning repository root: $repoFull"
        $allSolutions = @(Find-Solutions -Root $repoFull)
        $allProjects = @(Find-ProjectFiles -Root $repoFull)

        $solutions = @(Get-SolutionsReferencing -ProjectFile $projFull -Candidates $allSolutions)
        $consumers = @(Get-ConsumingProjects -ProjectFile $projFull -Candidates $allProjects)
        # Only literal references are reconciled by the CLI; non-literal/conditional ones are
        # warned about below (Write-UnreconcilableReferenceWarning) and left untouched.
        $ownRefs = @(Get-ProjectReferencePaths -ProjectFile $projFull | Where-Object { $_.IsLiteral })

        $slnNames = @(); foreach ($s in $solutions) { $slnNames += $s.Name }
        Write-Verbose "Plan: $projFile $oldDir -> $newDir"
        Write-Verbose " solutions referencing it : $($solutions.Count) ($($slnNames -join ', '))"
        Write-Verbose " consumer projects : $($consumers.Count)"
        Write-Verbose " its own references : $($ownRefs.Count)"

        # Debug: full membership matrix for the whole repository.
        if ($DebugPreference -ne 'SilentlyContinue') {
            foreach ($m in (Get-SolutionMembership -Solutions $allSolutions)) {
                Write-Debug "Solution $($m.Solution) lists $($m.Projects.Count) project(s):"
                foreach ($p in $m.Projects) { Write-Debug " $p" }
            }
        }

        # Divergence: the project is in some solutions but not others in the same repository.
        $refSlnPaths = @(); foreach ($s in $solutions) { $refSlnPaths += $s.FullName }
        $notReferencing = @($allSolutions | Where-Object { $refSlnPaths -notcontains $_.FullName })
        if ($solutions.Count -gt 0 -and $notReferencing.Count -gt 0) {
            $inNames = ($slnNames -join ', ')
            $outNameList = @(); foreach ($s in $notReferencing) { $outNameList += $s.Name }
            $outNames = ($outNameList -join ', ')
            $msg = "Solution divergence: '$projFile' is referenced by [$inNames] but not by [$outNames] in the same repository. Only the referencing solution(s) will be updated."
            if ($Strict) {
                $PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new(
                        [System.InvalidOperationException]::new($msg),
                        'SolutionDivergence', [System.Management.Automation.ErrorCategory]::InvalidData, $projFull))
            } else {
                Write-Warning $msg
            }
        }

        Test-DirectoryBuildInheritance -OldDir $oldDir -NewDir $newDir -RepositoryRoot $repoFull
        Write-UnreconcilableReferenceWarning -MovedProject $projFull -AllProjects $allProjects -LiteralConsumers $consumers

        $built = $null
        $performed = $false
        $skippedCount = 0

        if ($PSCmdlet.ShouldProcess("$projFile : $oldDir -> $newDir", 'Move .NET project and reconcile references')) {
            $ctx = Resolve-MoveContext -Cmdlet $PSCmdlet -Force:$Force -TargetForError $projFull
            if (-not $ctx) { return }

            $items = New-DotnetReferenceItems -Solutions $solutions -Consumers $consumers -OwnRefs $ownRefs `
                -OldProj $projFull -NewProj $newProj
            $move = { param($UseGit, $Src, $Dst, $Repository) Move-PathTracked -UseGit $UseGit -Source $Src -Destination $Dst -RepositoryRoot $Repository }

            # Files the reconciliation edits (for rollback): each solution, each consumer project,
            # and the moved project's own file. Reverse-move returns the folder to its old place.
            $backup = @($solutions | ForEach-Object { $_.FullName }) + @($consumers) + @($projFull)
            $planResult = Invoke-MovePlan -Caption "Move $projFile" -Items $items -Move $move `
                -MoveArgs @($ctx.UseGit, $oldDir, $newDir, $repoFull) `
                -BackupPath $backup -Rollback $move -RollbackArgs @($ctx.UseGit, $newDir, $oldDir, $repoFull) `
                -RepositoryRoot $repoFull -Command 'Move-DotnetProject' -Engine 'dotnet' `
                -Source $projFull -Destination $newProj `
                -UndoParams @{ Project = $newProj; Destination = $oldDir; Force = [bool]$Force } -NoJournal:$NoJournal
            $performed = $true
            $skippedCount = $planResult.Skipped

            if (-not $NoBuild) {
                & dotnet build $newProj
                $built = ($LASTEXITCODE -eq 0)
                if (-not $built) {
                    Write-Warning "Build failed after move. Review with 'git status'; revert with 'git restore .' if needed."
                }
            }
        }

        New-MoveResult -TypeName 'Netscoot.MoveResult' -Engine 'dotnet' -Source $projFull -Destination $newProj `
            -Performed $performed -SkippedCount $skippedCount -Extra @{
            Solutions     = $slnNames
            ConsumerCount = $consumers.Count
            OwnRefCount   = $ownRefs.Count
            Built         = $built
        }
    }
}