Netscoot.Unity/Public/Move-UnityAsset.ps1

function Move-UnityAsset {
    <#
    .SYNOPSIS
        Move a Unity asset or folder while keeping its paired .meta file(s), so the GUIDs
        that scene/prefab/asmdef references depend on survive the move.
 
    .DESCRIPTION
        In Unity every asset and folder has a sibling `<name>.meta` carrying a stable GUID.
        References (in scenes, prefabs, and asmdef "references" entries of the form
        "GUID:...") resolve by that GUID, not by path. If you move files on disk without
        their .meta, Unity regenerates fresh GUIDs and every reference to them breaks.
 
        This cmdlet moves the asset (git mv when tracked) together with its own .meta. For a
        folder, the descendant .meta files travel inside it and the folder's sibling .meta is
        moved too. asmdef references are by name/GUID (not path), so they do not need editing.
        When moving an .asmdef this reports who references it, for your awareness only.
 
        When the destination needs new parent folders, each one under Assets/ (or inside a
        package) gets a folder .meta with a fresh GUID, staged with the move, so it is committed
        once instead of being generated differently on every machine. Undo-Netscoot moves the
        asset back and removes those folders and their .meta files again, if they are empty.
 
        Cross-platform and target-agnostic: asmdef includePlatforms/excludePlatforms (iOS,
        Android, etc.) are plain fields untouched by a move, so mobile layouts are preserved.
 
    .PARAMETER AssetPath
        Asset file or folder to move (under Assets/ or a package). 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 asset/folder, following `git mv` rules: An existing directory means move
        into it, keeping the name. Any other path is the new path.
 
    .PARAMETER RepositoryRoot
        Root to scan for asmdef referencers. 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).
 
    .PARAMETER FoldersToPrune
        Set by Undo-Netscoot: the folders an earlier move created above AssetPath. After this move,
        each one that is empty is removed with its folder .meta.
 
    .OUTPUTS
        Netscoot.UnityMoveResult
 
    .EXAMPLE
        # Preview moving the asset/folder together with its .meta so GUIDs survive
        Move-UnityAsset -AssetPath ./Assets/Plugins/Tarragon -Destination ./Assets/Lib/Tarragon -WhatIf
        # Move it for real
        Move-UnityAsset -AssetPath ./Assets/Plugins/Tarragon -Destination ./Assets/Lib/Tarragon
        # Destination is an existing folder -> lands at ./Assets/Lib/Tarragon
        Move-UnityAsset -AssetPath ./Assets/Plugins/Tarragon -Destination ./Assets/Lib
    #>

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

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

    process {
        $src = Resolve-FullPath $AssetPath
        if (-not (Test-Path -LiteralPath $src)) {
            $PSCmdlet.WriteError([System.Management.Automation.ErrorRecord]::new(
                    [System.IO.FileNotFoundException]::new("Asset not found: $AssetPath"),
                    'AssetNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $AssetPath))
            return
        }

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

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

        if ($src -notmatch '[\\/](Assets|Packages)[\\/]') {
            Write-Warning "Asset is not under an 'Assets/' or 'Packages/' folder; .meta/GUID semantics only apply inside a Unity project."
        }
        if (-not $hasMeta) {
            Write-Warning "No .meta found for $([System.IO.Path]::GetFileName($src)); Unity will generate a new GUID on import, which can break existing references."
        }

        # When moving an .asmdef, report who references it (name/GUID refs are stable - info only).
        $referencers = @()
        $isAsmdef = ([System.IO.Path]::GetExtension($src) -eq '.asmdef')
        if ($isAsmdef) { $referencers = @(Get-AsmdefReferencers -AsmdefPath $src -RepositoryRoot $repoFull) }

        # Parent folders the move creates. Unity gives each one under Assets/ (or inside a package)
        # a .meta with a fresh GUID on import; creating it here means it is committed with the move
        # instead of every machine generating a different one.
        $newFolders = @()
        $parent = Split-Path -Parent $dst
        while ($parent -and -not (Test-Path -LiteralPath $parent)) { $newFolders = @($parent) + $newFolders; $parent = Split-Path -Parent $parent }
        $folderMetas = @($newFolders | Where-Object {
                $leaf = Split-Path -Leaf $_
                $leaf -notlike '.*' -and $leaf -notlike '*~' -and ($_ -match '[\\/]Assets[\\/].+' -or $_ -match '[\\/]Packages[\\/][^\\/]+[\\/].+')
            } | ForEach-Object { "$_.meta" })

        Write-MovePlan -Cmdlet $PSCmdlet -Caption "Move-UnityAsset $([System.IO.Path]::GetFileName($src)) $src -> $dst" -Items ([ordered]@{
                'paired .meta moves alongside' = $hasMeta
                'is .asmdef'                   = $isAsmdef
                'referencing asmdefs (by name/GUID; survive the move)' = $referencers
                'new folders getting a .meta'  = $folderMetas
            })

        $performed = $false
        if ($PSCmdlet.ShouldProcess("$src -> $dst (with .meta)", 'Move Unity asset and its .meta')) {
            $ctx = Resolve-MoveContext -Cmdlet $PSCmdlet -Force:$Force -TargetForError $src
            if (-not $ctx) { return }

            # Unity references resolve by GUID (carried in the .meta), so there are no
            # reference edits to confirm - moving the asset + its .meta is the whole operation.
            # Removes, deepest first, each listed folder that sits on $Anchor's parent chain inside the
            # repository and is empty, together with its folder .meta. A folder whose .meta is not a
            # folder .meta is left alone.
            $pruneFolders = {
                param($Folders, $Anchor, $UseGit, $RepoFull)
                $deepestFirst = @($Folders | Where-Object { (Test-PathUnder $Anchor $_) -and (Test-PathUnder $_ $RepoFull) } |
                        Sort-Object -Property Length -Descending)
                foreach ($d in $deepestFirst) {
                    if (-not (Test-Path -LiteralPath $d -PathType Container) -or (Get-ChildItem -LiteralPath $d -Force)) { continue }
                    $meta = "$d.meta"
                    if (Test-Path -LiteralPath $meta -PathType Leaf) {
                        if ([System.IO.File]::ReadAllText($meta) -notmatch '(?m)^folderAsset: yes\s*$') { continue }
                        if ($UseGit -and (Test-GitTracked -Path $meta)) { Invoke-Git -RepositoryRoot $RepoFull -Arguments @('rm', '-q', '-f', '--', $meta) }
                        else { Remove-Item -LiteralPath $meta }
                    }
                    Remove-Item -LiteralPath $d
                }
            }
            # $moveBack undoes whatever part of the move happened, so it serves both a failure inside
            # $move (keeping the move all-or-nothing) and the plan's rollback.
            $moveBack = {
                param($UseGit, $Src, $Dst, $SrcMeta, $DstMeta, $RepoFull, $NewFolders, $PruneFolders)
                if ((Test-Path -LiteralPath $DstMeta) -and -not (Test-Path -LiteralPath $SrcMeta)) {
                    Move-PathTracked -UseGit $UseGit -Source $DstMeta -Destination $SrcMeta -RepositoryRoot $RepoFull
                }
                if (Test-Path -LiteralPath $Dst) { Move-PathTracked -UseGit $UseGit -Source $Dst -Destination $Src -RepositoryRoot $RepoFull }
                & $PruneFolders @($NewFolders) $Dst $UseGit $RepoFull
            }
            $move = {
                param($UseGit, $Src, $Dst, $SrcMeta, $DstMeta, $HasMeta, $RepoFull, $NewFolders, $FolderMetas, $MoveBack, $PruneFolders)
                try {
                    foreach ($d in @($NewFolders)) { New-Item -ItemType Directory -Path $d | Out-Null }
                    foreach ($m in @($FolderMetas)) {
                        $guid = [guid]::NewGuid().ToString('N')
                        [System.IO.File]::WriteAllText($m, "fileFormatVersion: 2`nguid: $guid`nfolderAsset: yes`nDefaultImporter:`n externalObjects: {}`n userData: `n assetBundleName: `n assetBundleVariant: `n", [System.Text.UTF8Encoding]::new($false))
                        if ($UseGit) { Invoke-Git -RepositoryRoot $RepoFull -Arguments @('add', '--', $m) }
                    }
                    Move-PathTracked -UseGit $UseGit -Source $Src -Destination $Dst -RepositoryRoot $RepoFull
                    if ($HasMeta) { Move-PathTracked -UseGit $UseGit -Source $SrcMeta -Destination $DstMeta -RepositoryRoot $RepoFull }
                } catch {
                    & $MoveBack $UseGit $Src $Dst $SrcMeta $DstMeta $RepoFull @($NewFolders) $PruneFolders
                    throw
                }
            }
            # Undo replays the reverse move; handing it the folders this move creates lets it prune them.
            $undoParams = @{ AssetPath = $dst; Destination = $src; Force = [bool]$Force }
            if ($newFolders.Count) { $undoParams['FoldersToPrune'] = $newFolders }
            Invoke-MovePlan -Caption "Move Unity asset $(Split-Path -Leaf $src)" -Items @() -Move $move `
                -MoveArgs @($ctx.UseGit, $src, $dst, $srcMeta, $dstMeta, $hasMeta, $repoFull, $newFolders, $folderMetas, $moveBack, $pruneFolders) `
                -Rollback $moveBack -RollbackArgs @($ctx.UseGit, $src, $dst, $srcMeta, $dstMeta, $repoFull, $newFolders, $pruneFolders) `
                -RepositoryRoot $repoFull -Command 'Move-UnityAsset' -Engine 'unity' -Source $src -Destination $dst `
                -UndoParams $undoParams -NoJournal:$NoJournal | Out-Null
            $performed = $true
            Write-Verbose "Moved asset$(if ($hasMeta) { ' + .meta' })."

            if ($FoldersToPrune) {
                try { & $pruneFolders @($FoldersToPrune | ForEach-Object { Resolve-FullPath $_ }) $src $ctx.UseGit $repoFull }
                catch { Write-Warning "The asset moved, but an empty folder it left could not be removed: $($_.Exception.Message)" }
            }
        }

        New-MoveResult -TypeName 'Netscoot.UnityMoveResult' -Engine 'unity' -Source $src -Destination $dst `
            -Performed $performed -SkippedCount 0 -Extra ([ordered]@{
                MetaMoved    = ($performed -and $hasMeta)
                IsAsmdef     = $isAsmdef
                ReferencedBy = $referencers
            })
    }
}