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 pipeline input (a path
        string or a Get-ChildItem/Get-Item item; other object types are rejected).
 
    .PARAMETER Destination
        Where to move the asset/folder, following `git mv` rules: An existing directory means move
        into it (keeping the name); otherwise it is the new path.
 
    .PARAMETER RepositoryRoot
        Root to scan for asmdef referencers. Defaults to the enclosing git repository root.
 
    .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).
 
    .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; moves 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
            })
    }
}