Netscoot.Native/Public/Move-NativeProject.ps1

function Move-NativeProject {
    <#
    .SYNOPSIS
        Move a native or C++/CLI project (.vcxproj), update the solutions and projects that
        reference it, and report the native path-bearing settings it does not rewrite so they
        are never silently broken. Windows-only.
 
    .DESCRIPTION
        Native projects link through MSBuild settings that a move can break:
        AdditionalIncludeDirectories / AdditionalLibraryDirectories / AdditionalDependencies,
        `<Import>` of shared .props/.targets, $(SolutionDir)-relative OutDir, and the paired
        vcxproj.filters file. C++/CLI is Windows-only, so this cmdlet refuses to run elsewhere.
 
        It will: move the folder (git mv when tracked) with its paired .vcxproj.filters; rewrite
        the project's path in each .sln/.slnx entry, in every ProjectReference to it (native or
        managed consumers) and in its own ProjectReferences, keeping GUIDs, platform mappings and
        solution folders as they are; and report native settings for a human to verify: every
        relative or SolutionDir-relative setting in the moved project (returned as
        UnreconciledSettings), and every parent-relative (..) setting in another project that
        points into its folder (written as warnings). It does not rewrite those MSBuild settings. The dotnet CLI is not used:
        it cannot load a .vcxproj outside Visual Studio's MSBuild.
 
    .PARAMETER Project
        Path to the .vcxproj. Accepts a path string or a Get-ChildItem/Get-Item item from the pipeline, and rejects other object types.
 
    .PARAMETER Destination
        Where to move the project folder, following `git mv` rules: An existing directory means
        move into it, keeping the name. Any other path is the new folder path.
 
    .PARAMETER RepositoryRoot
        Root to scan for solutions. Defaults to the enclosing git repository root.
 
    .PARAMETER Force
        When git is not installed, move with a plain PowerShell `Move-Item` without asking first.
        Without -Force it asks before falling back. The plain move 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.NativeMoveResult
 
    .EXAMPLE
        # Preview, including the native path settings it cannot reconcile (verify by hand after)
        Move-NativeProject -Project ./Aleppo/Aleppo.vcxproj -Destination ./native/Aleppo -WhatIf
        # Move it (also moves the paired .vcxproj.filters)
        Move-NativeProject -Project ./Aleppo/Aleppo.vcxproj -Destination ./native/Aleppo
        # Move into an existing folder (lands at ./native/Aleppo)
        Move-NativeProject -Project ./Aleppo/Aleppo.vcxproj -Destination ./native
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType('Netscoot.NativeMoveResult')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
        [Netscoot.PathInputTransform()]
        [ValidateNotNullOrEmpty()]
        [string]$Project,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Destination,
        [string]$RepositoryRoot,
        [switch]$Force,
        [switch]$NoJournal
    )

    process {
        if (-not (Test-IsWindowsHost)) {
            $PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new(
                    [System.PlatformNotSupportedException]::new("Native/C++ projects are Windows-only; Move-NativeProject cannot run on this OS."),
                    'WindowsOnly', [System.Management.Automation.ErrorCategory]::NotImplemented, $Project))
            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 ([System.IO.Path]::GetExtension($projFull).ToLowerInvariant() -eq '.vcproj') {
            $PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new(
                    [System.NotSupportedException]::new("'$Project' is a legacy Visual C++ project (.vcproj, pre-VS2010), which predates MSBuild and is not supported. Convert it to .vcxproj (open it in Visual Studio 2010 or later), then retry."),
                    'LegacyVcprojNotSupported', [System.Management.Automation.ErrorCategory]::NotImplemented, $Project))
            return
        }
        if (-not (Test-IsNativeProject $projFull)) {
            $PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new(
                    [System.ArgumentException]::new("Not a native project (.vcxproj): $Project. Use Move-DotnetProject for managed projects."),
                    'NotANativeProject', [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"; 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
        }

        # One repository parse for this invocation; solutions-referencing reuses the embedded parse.
        $workspace = Get-Workspace -RepositoryRoot $repoFull
        $allSolutions = @(Get-WorkspaceSolutions -Workspace $workspace)
        $solutions = @(Get-SolutionsReferencing -ProjectFile $projFull -Candidates $allSolutions)
        $nativeSettings = @(Get-NativePathSettings -ProjectFile $projFull)
        $filters = "$projFull.filters"
        $hasFilters = Test-Path -LiteralPath $filters

        # The dotnet CLI cannot evaluate a .vcxproj outside Visual Studio's MSBuild and would drop the
        # entry's platform mapping and GUID, so every path is rewritten in place instead.
        $pointAt = { param($Ref, $Target) [void]$Ref.PointAt($Target) }
        $followFile = { param($Ref, $NewFile) [void]$Ref.FollowFile($NewFile) }
        $incoming = @()
        foreach ($s in $solutions) {
            $incoming += @(Get-SolutionProjectEntries -SolutionFile $s.FullName | Where-Object { Test-PathEqual $_.Target $projFull })
        }
        $consumers = @(Get-WorkspaceConsumingProjects -Workspace $workspace -ProjectFile $projFull)
        foreach ($c in $consumers) {
            foreach ($r in @(Get-WorkspaceProjectRefs -Workspace $workspace -ProjectFile $c | Where-Object { $_.IsLiteral -and (Test-PathEqual $_.FullPath $projFull) })) {
                $incoming += [Netscoot.StoredPath]::InAttribute($c, 'Include', $r.Raw, $projFull)
            }
        }
        $outgoing = @(Get-WorkspaceProjectRefs -Workspace $workspace -ProjectFile $projFull | Where-Object { $_.IsLiteral } |
            ForEach-Object { [Netscoot.StoredPath]::InAttribute($projFull, 'Include', $_.Raw, $_.FullPath) } |
            Where-Object { $_.RawFollowing($newProj) -ne $_.Raw })
        $items = @()
        foreach ($ref in $incoming) {
            $items += New-MoveItem -Description "$(Split-Path -Leaf $ref.File): $($ref.Raw) -> $($ref.RawPointingAt($newProj))" `
                -Reattach $pointAt -ReattachArgs @($ref, $newProj)
        }
        foreach ($ref in $outgoing) {
            $items += New-MoveItem -Description "own reference: $($ref.Raw) -> $($ref.RawFollowing($newProj))" `
                -Reattach $followFile -ReattachArgs @($ref, $newProj)
        }
        Write-UnreconcilableReferenceWarning -MovedProject $projFull -AllProjects @(Get-WorkspaceProjectFiles -Workspace $workspace -IncludeNative) `
            -LiteralConsumers $consumers -Workspace $workspace

        # Other projects' native settings that resolve into the moved folder break too; report them.
        $pointingIn = @()
        foreach ($other in @(Get-WorkspaceProjectFiles -Workspace $workspace -IncludeNative | Where-Object { $_.Extension -eq '.vcxproj' })) {
            if (Test-PathEqual $other.Abs $projFull) { continue }
            $otherDir = Split-Path -Parent $other.Abs
            foreach ($setting in @(Get-NativePathSettings -ProjectFile $other.Abs)) {
                foreach ($entry in ($setting.Value -split ';')) {
                    $candidate = $entry.Trim() -replace '^\$\((ProjectDir|MSBuildThisFileDirectory)\)', ''
                    if (-not $candidate -or $candidate -match '[$%]\(') { continue }
                    $abs = [System.IO.Path]::GetFullPath((Join-Path $otherDir $candidate.Replace('\', [System.IO.Path]::DirectorySeparatorChar)))
                    if ((Test-PathEqual $abs $oldDir) -or (Test-PathUnder $abs $oldDir)) {
                        $pointingIn += "$($other.Name): [$($setting.Kind)] $($entry.Trim())"
                    }
                }
            }
        }

        $slnNames = @(); foreach ($s in $solutions) { $slnNames += $s.Name }
        $settingLines = @($nativeSettings | ForEach-Object { "[$($_.Kind)] $($_.Value)" })
        Write-MovePlan -Cmdlet $PSCmdlet -Caption "Move-NativeProject $projFile $oldDir -> $newDir" -Items ([ordered]@{
                'solutions to update'                          = $slnNames
                'consuming projects to update'                 = @($consumers | ForEach-Object { Split-Path -Leaf $_ })
                'paired .filters file moving'                  = $hasFilters
                'unreconciled native settings (manual review)' = $settingLines
                'other projects pointing into the folder'      = $pointingIn
            })

        $performed = $false
        $skippedCount = 0

        if ($PSCmdlet.ShouldProcess("$projFile : $oldDir -> $newDir", 'Move native project and update solution entries and project references (native paths reported only)')) {
            $ctx = Resolve-MoveContext -Cmdlet $PSCmdlet -Force:$Force -TargetForError $projFull
            if (-not $ctx) { return }

            $move = { param($UseGit, $Src, $Dst, $Repository) Move-PathTracked -UseGit $UseGit -Source $Src -Destination $Dst -RepositoryRoot $Repository }

            $backup = @($incoming | ForEach-Object { $_.File }) + @($projFull)
            $planResult = Invoke-MovePlan -Caption "Move native $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-NativeProject' -Engine 'native' -Source $projFull -Destination $newProj `
                -UndoParams @{ Project = $newProj; Destination = $oldDir; Force = [bool]$Force } -NoJournal:$NoJournal
            $performed = $true
            $skippedCount = $planResult.Skipped
        }

        if ($nativeSettings.Count -gt 0) {
            Write-Warning "$($nativeSettings.Count) native path setting(s) in $projFile are not auto-reconciled - verify by hand or with a native engine:"
            foreach ($s in $nativeSettings) { Write-Warning " [$($s.Kind)] $($s.Value)" }
        }
        if ($hasFilters) {
            Write-Warning "Paired .filters for $projFile moved with the folder; its entries are project-relative and usually survive, but confirm no parent-relative entries broke."
        }
        foreach ($line in $pointingIn) {
            Write-Warning "Points into the moved folder, not auto-reconciled - verify by hand: $line"
        }

        New-MoveResult -TypeName 'Netscoot.NativeMoveResult' -Engine 'native' -Source $projFull -Destination $newProj `
            -Performed $performed -SkippedCount $skippedCount -Extra ([ordered]@{
                Solutions            = $slnNames
                UnreconciledSettings = $nativeSettings
                HadFilters           = $hasFilters
            })
    }
}