Netscoot.Core/Public/Move-MSBuildImport.ps1

function Move-MSBuildImport {
    <#
    .SYNOPSIS
        Move a shared MSBuild .props/.targets file and fix every project (or other
        props/targets) that imports it via `<Import Project="...">`.
 
    .DESCRIPTION
        There is no dotnet CLI for `<Import>`, so this reconciles the relative Import paths
        directly with text edits that keep each file's formatting and encoding (it replaces the
        exact `Project="<value>"` token captured from the XML). It also fixes the moved file's
        own outgoing `<Import>` paths, which break when its location changes. The
        $(MSBuildThisFileDirectory) token is resolved and preserved. Other $(...) tokens in the
        moved file's own imports are reported as unresolved rather than guessed. An importer that
        reaches the file through any token other than $(MSBuildThisFileDirectory) is not detected.
 
        Note: Directory.Build.props/.targets (and Directory.Packages.props, etc.) are imported
        by location, not an explicit `<Import>` - moving one changes inheritance scope, which
        cannot be "fixed" by editing imports. For those this warns (like the inheritance check)
        and only fixes the file's own outgoing imports.
 
        Importers may include native .vcxproj files. Their `<Import>` path is fixed on any OS (a
        best-effort, path-only update). A .vcxproj's native link settings are never rewritten,
        and Move-NativeProject (Windows) reports them when the project itself moves.
 
        dotnet is not required here. git is used when available (otherwise a plain move, confirmed
        first or forced with -Force). Supports -WhatIf.
 
    .PARAMETER Path
        The .props/.targets file to move. Accepts a path string or a Get-ChildItem/Get-Item item from the pipeline, and rejects other object types.
 
    .PARAMETER Destination
        New file path (or a folder, in which case the file keeps its name).
 
    .PARAMETER RepositoryRoot
        Root to scan for importers. 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.ImportMoveResult
 
    .EXAMPLE
        # Move a shared props/targets and fix every consumer's Import path
        Move-MSBuildImport -Path ./Shared.props -Destination ./build/Shared.props -WhatIf
        # Move into an existing folder (lands at ./build/Shared.props)
        Move-MSBuildImport -Path ./Shared.props -Destination ./build
        # A by-location import (Directory.Build.props): moving it changes inheritance scope - reported
        Move-MSBuildImport -Path ./src/Directory.Build.props -Destination ./Directory.Build.props
    #>

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

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

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

    process {
        $src = Resolve-FullPath $Path
        if (-not (Test-Path -LiteralPath $src -PathType Leaf)) {
            $PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new(
                    [System.IO.FileNotFoundException]::new("File not found: $Path"),
                    'FileNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $Path))
            return
        }
        if ([System.IO.Path]::GetExtension($src) -notin '.props', '.targets') {
            $PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new(
                    [System.ArgumentException]::new("Not a .props/.targets file: $Path"),
                    'NotAnImportFile', [System.Management.Automation.ErrorCategory]::InvalidArgument, $Path))
            return
        }

        $srcName = Split-Path -Leaf $src
        # git mv semantics (shared by every mover): existing dir -> move into it; else rename.
        $newPath = Resolve-MoveTarget -Source $src -Destination $Destination
        if (Test-Path -LiteralPath $newPath) {
            $PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new(
                    [System.IO.IOException]::new("Destination already exists: $newPath"),
                    'DestinationExists', [System.Management.Automation.ErrorCategory]::ResourceExists, $newPath))
            return
        }

        if (-not $RepositoryRoot) { $RepositoryRoot = Get-RepositoryRoot -StartPath (Split-Path -Parent $src) }
        $repoFull = Resolve-FullPath $RepositoryRoot

        $autoImported = ($srcName -match '^Directory\.(Build|Packages|Solution)\.(props|targets)$')

        # Importers: files whose <Import> resolves to the moved file.
        $importers = @()
        if (-not $autoImported) {
            foreach ($f in (Find-MSBuildFiles -Root $repoFull)) {
                if (Test-PathEqual $f.FullName $src) { continue }
                $importers += @((Get-ImportPaths -ProjectFile $f.FullName).Paths | Where-Object { Test-PathEqual $_.Target $src })
            }
        }

        # The moved file's own outgoing imports (break when its location changes).
        $own = Get-ImportPaths -ProjectFile $src
        $ownImports = @($own.Paths | Where-Object { $_.RawFollowing($newPath) -ne $_.Raw })
        $ownUnresolved = @($own.Unresolved)

        $importerRels = @($importers | ForEach-Object { $_.File })
        $ownImportRaw = @($ownImports | ForEach-Object { $_.Raw })
        Write-MovePlan -Cmdlet $PSCmdlet -Caption "Move-MSBuildImport $srcName $src -> $newPath" -Items ([ordered]@{
                'importers to fix'        = $importerRels
                'own imports to fix'      = $ownImportRaw
                'auto-imported by location' = $autoImported
            })
        if ($autoImported) {
            Write-Warning "$srcName is imported by location (auto-import). Moving it changes which projects inherit it; that inheritance cannot be fixed by editing <Import> - verify the new location applies to the intended projects."
        }
        foreach ($u in $ownUnresolved) {
            Write-Warning "Own <Import Project=`"$u`"> uses an unresolved MSBuild variable; fix it by hand if its target is outside the moved file."
        }

        $performed = $false

        if ($PSCmdlet.ShouldProcess("$src -> $newPath", 'Move MSBuild import and fix <Import> consumers')) {
            $ctx = Resolve-MoveContext -Cmdlet $PSCmdlet -Force:$Force -TargetForError $src
            if (-not $ctx) { return }

            # Import fixes happen after the move; Reattach-only items.
            $pointAt = { param($Ref, $Target) [void]$Ref.PointAt($Target) }
            $followFile = { param($Ref, $NewFile) [void]$Ref.FollowFile($NewFile) }
            $items = @()
            foreach ($imp in $importers) {
                $items += New-MoveItem -Description "importer $(Split-Path -Leaf $imp.File): $($imp.Raw) -> $($imp.RawPointingAt($newPath))" `
                    -Reattach $pointAt -ReattachArgs @($imp, $newPath)
            }
            foreach ($ref in $ownImports) {
                $items += New-MoveItem -Description "own import: $($ref.Raw) -> $($ref.RawFollowing($newPath))" `
                    -Reattach $followFile -ReattachArgs @($ref, $newPath)
            }
            $move = { param($UseGit, $Src, $Dst, $Repository) Move-PathTracked -UseGit $UseGit -Source $Src -Destination $Dst -RepositoryRoot $Repository }

            $backup = @($importers | ForEach-Object { $_.File }) + @($src)
            Invoke-MovePlan -Caption "Move import $srcName" -Items $items -Move $move `
                -MoveArgs @($ctx.UseGit, $src, $newPath, $repoFull) `
                -BackupPath $backup -Rollback $move -RollbackArgs @($ctx.UseGit, $newPath, $src, $repoFull) `
                -RepositoryRoot $repoFull -Command 'Move-MSBuildImport' -Engine 'dotnet' -Source $src -Destination $newPath `
                -UndoParams @{ Path = $newPath; Destination = $src; Force = [bool]$Force } -NoJournal:$NoJournal
            $performed = $true
        }

        New-MoveResult -TypeName 'Netscoot.ImportMoveResult' -Engine 'dotnet' -Source $src -Destination $newPath `
            -Performed $performed -Extra ([ordered]@{
                ImportersFixed  = $importers.Count
                OwnImportsFixed = $ownImports.Count
                AutoImported    = $autoImported
            })
    }
}