Private/Migration/New-CgrMigrationPlan.ps1

function New-CgrMigrationPlan {
    <#
    .SYNOPSIS
    Builds the immutable migration plan reviewed before repository mutation.

    .DESCRIPTION
    Normalizes source and destination identity, rejects unsafe archive/destination
    conflicts, captures approved source Git evidence and transferable protection,
    and produces the ordered steps for Snapshot or FullHistory execution. Planning
    itself does not perform the repository-copy mutation; the returned source-state
    evidence is later revalidated so execution fails closed if the source changes.

    .NOTES
    Replacement modes deliberately require unused archive names. The plan is the
    safety contract between review and execution and must not be reconstructed from
    live source state after the user has approved it.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSUseShouldProcessForStateChangingFunctions',
        '',
        Justification = 'This private helper builds a dry-run plan object only; it performs no mutation.'
    )]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [psobject] $SourceRepository,

        [Parameter(Mandatory)]
        [ValidatePattern('^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$')]
        [string] $DestinationRepository,

        [Parameter(Mandatory)]
        [ValidateSet('Snapshot', 'FullHistory')]
        [string] $ContentMode,

        [Parameter(Mandatory)]
        [ValidateSet('public', 'private', 'internal')]
        [string] $DestinationVisibility,

        [string] $ArchiveRepositoryName,

        [string] $ExistingDestinationArchiveName,

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

        [switch] $RestorePages,

        [switch] $EnableActionsAfterMigration,

        [switch] $SkipSettings,

        [ValidateNotNullOrEmpty()]
        [string] $HostName = 'github.com',

        [switch] $PlanOnly
    )

    $normalizedDestination = ConvertTo-CgrRepositoryName -Repository $DestinationRepository
    $sourceFullName = ConvertTo-CgrRepositoryName -Repository $SourceRepository.FullName

    if ([string]::IsNullOrWhiteSpace([string] $SourceRepository.DefaultBranch)) {
        $message = "Source repository '$sourceFullName' is empty and has no default branch to copy. Add at least one commit before copying the repository."
        $exception = [System.InvalidOperationException]::new($message)
        $errorRecord = [System.Management.Automation.ErrorRecord]::new(
            $exception,
            'SourceRepositoryEmpty',
            [System.Management.Automation.ErrorCategory]::InvalidData,
            $sourceFullName
        )
        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }

    $isSameNameReplacement = $sourceFullName -eq $normalizedDestination
    $destinationExists = $false
    $isExistingDestinationReplacement = $false
    $archiveFullName = $null

    if ($isSameNameReplacement) {
        if ([string]::IsNullOrWhiteSpace($ArchiveRepositoryName)) {
            $exception = [System.InvalidOperationException]::new(
                'Same-name replacement planning requires -ArchiveRepositoryName so the original repository can be preserved.'
            )
            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                $exception,
                'ArchiveRepositoryNameRequired',
                [System.Management.Automation.ErrorCategory]::InvalidArgument,
                $DestinationRepository
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }

        $archiveFullName = "$($SourceRepository.Owner)/$ArchiveRepositoryName"
        if (Test-CgrGitHubRepositoryExistence -Repository $archiveFullName -HostName $HostName) {
            $exception = [System.InvalidOperationException]::new(
                "Archive repository '$archiveFullName' already exists. Choose an unused archive repository name."
            )
            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                $exception,
                'ArchiveRepositoryAlreadyExists',
                [System.Management.Automation.ErrorCategory]::ResourceExists,
                $archiveFullName
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }
    }
    else {
        $destinationExists = Test-CgrGitHubRepositoryExistence -Repository $normalizedDestination -HostName $HostName
        if ($destinationExists) {
            if ([string]::IsNullOrWhiteSpace($ExistingDestinationArchiveName)) {
                $exception = [System.InvalidOperationException]::new(
                    "Destination repository '$normalizedDestination' already exists. The tool will not overwrite an existing repository."
                )
                $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                    $exception,
                    'DestinationRepositoryAlreadyExists',
                    [System.Management.Automation.ErrorCategory]::ResourceExists,
                    $normalizedDestination
                )
                $PSCmdlet.ThrowTerminatingError($errorRecord)
            }

            $destinationParts = $normalizedDestination.Split('/', 2)
            $archiveFullName = "$($destinationParts[0])/$ExistingDestinationArchiveName"
            if (Test-CgrGitHubRepositoryExistence -Repository $archiveFullName -HostName $HostName) {
                $exception = [System.InvalidOperationException]::new(
                    "Archive repository '$archiveFullName' already exists. Choose an unused archive repository name."
                )
                $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                    $exception,
                    'ExistingDestinationArchiveAlreadyExists',
                    [System.Management.Automation.ErrorCategory]::ResourceExists,
                    $archiveFullName
                )
                $PSCmdlet.ThrowTerminatingError($errorRecord)
            }

            $isExistingDestinationReplacement = $true
        }
        elseif (-not [string]::IsNullOrWhiteSpace($ExistingDestinationArchiveName)) {
            $exception = [System.InvalidOperationException]::new(
                "Existing-destination archive name was supplied, but destination repository '$normalizedDestination' does not exist."
            )
            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                $exception,
                'ExistingDestinationArchiveNotRequired',
                [System.Management.Automation.ErrorCategory]::InvalidArgument,
                $normalizedDestination
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }
    }

    $sourceRepositoryId = Get-CgrObjectProperty -InputObject $SourceRepository -Name 'Id'
    $protectionConfiguration = $null
    $protectionPlanningStatus = if ($SkipSettings) {
        'SkippedWithSettings'
    }
    elseif ($null -eq $sourceRepositoryId) {
        'SkippedSourceIdentityUnavailable'
    }
    else {
        $protectionConfiguration = Get-CgrRepositoryProtectionConfiguration `
            -Repository $SourceRepository `
            -HostName $HostName
        'Captured'
    }

    $protectionPlan = [pscustomobject] @{
        Status = $protectionPlanningStatus
        RulesetCount = if ($protectionConfiguration) { @($protectionConfiguration.Rulesets).Count } else { 0 }
        DefaultBranchProtectionCaptured = [bool] ($protectionConfiguration -and $protectionConfiguration.BranchProtection)
        Unsupported = if ($protectionConfiguration) { @($protectionConfiguration.Unsupported) } else { @() }
        Configuration = $protectionConfiguration
    }

    $sourceState = Get-CgrApprovedSourceState -Repository $SourceRepository -ContentMode $ContentMode

    $steps = [System.Collections.Generic.List[object]]::new()
    if ($isSameNameReplacement) {
        $steps.Add([pscustomobject] @{
                Order = 1
                Name = 'PreserveSourceAsArchive'
                Description = "Preserve the original repository by renaming '$sourceFullName' to '$archiveFullName'."
                MutatesGitHub = $true
            })
        $steps.Add([pscustomobject] @{
                Order = 2
                Name = 'CreateReplacementRepository'
                Description = "Create a fresh '$normalizedDestination' repository with '$DestinationVisibility' visibility."
                MutatesGitHub = $true
            })
    }
    elseif ($isExistingDestinationReplacement) {
        $steps.Add([pscustomobject] @{
                Order = 1
                Name = 'PreserveDestinationAsArchive'
                Description = "Preserve the existing destination by renaming '$normalizedDestination' to '$archiveFullName'."
                MutatesGitHub = $true
            })
        $steps.Add([pscustomobject] @{
                Order = 2
                Name = 'CreateReplacementRepository'
                Description = "Create a fresh '$normalizedDestination' repository with '$DestinationVisibility' visibility."
                MutatesGitHub = $true
            })
    }
    else {
        $steps.Add([pscustomobject] @{
                Order = 1
                Name = 'CreateDestinationRepository'
                Description = "Create '$normalizedDestination' with '$DestinationVisibility' visibility."
                MutatesGitHub = $true
            })
    }

    $steps.Add([pscustomobject] @{
            Order = $steps.Count + 1
            Name = "Copy$ContentMode"
            Description = if ($ContentMode -eq 'Snapshot') {
                "Publish the approved '$($SourceRepository.DefaultBranch)' branch content from '$sourceFullName' as one new root commit. Prior commit history, other branches, and tags are not published."
            }
            else {
                "Copy the approved Git history from '$sourceFullName', preserving branches, tags, commits, and reachable Git LFS objects."
            }
            MutatesGitHub = $true
        })

    $steps.Add([pscustomobject] @{
            Order = $steps.Count + 1
            Name = 'VerifyMigration'
            Description = if ($ContentMode -eq 'Snapshot') {
                'Verify the published Snapshot content against the approved source tree before restoring repository configuration.'
            }
            else {
                'Verify the copied history, branches, tags, commits, and Git LFS availability against the approved source state before restoring repository configuration.'
            }
            MutatesGitHub = $false
        })

    if (-not $SkipSettings) {
        $steps.Add([pscustomobject] @{
                Order = $steps.Count + 1
                Name = 'RestoreSupportedSettings'
                Description = 'Restore supported repository settings and topics after content verification.'
                MutatesGitHub = $true
            })
        $rulesetCount = if ($protectionConfiguration) { @($protectionConfiguration.Rulesets).Count } else { 0 }
        $hasDefaultBranchProtection = [bool] ($protectionConfiguration -and $protectionConfiguration.BranchProtection)
        $protectionDescription = if ($protectionPlanningStatus -ne 'Captured') {
            'Repository protection could not be fully inspected during planning. Only protection that can be restored safely will be applied, and anything unavailable will be reported.'
        }
        elseif ($rulesetCount -eq 0 -and -not $hasDefaultBranchProtection) {
            'No transferable repository protection was found.'
        }
        elseif ($rulesetCount -gt 0 -and $hasDefaultBranchProtection) {
            "Restore $rulesetCount transferable repository ruleset(s) and default-branch protection after content verification. Identity-bound protection will be reported rather than weakened."
        }
        elseif ($rulesetCount -gt 0) {
            "Restore $rulesetCount transferable repository ruleset(s) after content verification. Identity-bound protection will be reported rather than weakened."
        }
        else {
            'Restore transferable default-branch protection after content verification. Identity-bound protection will be reported rather than weakened.'
        }
        $steps.Add([pscustomobject] @{
                Order = $steps.Count + 1
                Name = 'RestoreRepositoryProtection'
                Description = $protectionDescription
                MutatesGitHub = $true
            })
    }

    $mode = if ($isSameNameReplacement) {
        'SameNameReplacement'
    }
    elseif ($isExistingDestinationReplacement) {
        'ExistingDestinationReplacement'
    }
    else {
        'NewDestination'
    }

    [pscustomobject] @{
        PSTypeName = 'CopyGitHubRepo.MigrationPlan'
        SchemaVersion = 1
        Mode = $mode
        HostName = $HostName
        SourceRepository = $sourceFullName
        SourceDefaultBranch = $SourceRepository.DefaultBranch
        SourceVisibility = $SourceRepository.Visibility
        SourceState = $sourceState
        DestinationRepository = $normalizedDestination
        DestinationExistedBeforeMigration = [bool] $destinationExists
        ArchiveRepository = $archiveFullName
        ContentMode = $ContentMode
        DestinationVisibility = $DestinationVisibility
        CommitMessage = $CommitMessage
        RestorePages = [bool] $RestorePages
        EnableActionsAfterMigration = [bool] $EnableActionsAfterMigration
        SkipSettings = [bool] $SkipSettings
        Protection = $protectionPlan
        WillMutateGitHub = -not [bool] $PlanOnly
        PlanOnly = [bool] $PlanOnly
        Steps = $steps.ToArray()
    }
}