Public/Copy-GitHubRepository.ps1

function Copy-GitHubRepository {
    <#
    .SYNOPSIS
    Publishes or copies a GitHub repository from an approved repository copy plan.

    .DESCRIPTION
    Plans or executes a GitHub repository copy. Snapshot is the default and
    publishes the approved source default-branch content as one unrelated root
    commit without prior Git history, other branches, or tags. FullHistory is
    explicit and preserves the approved branches, tags, commits, and reachable
    Git LFS objects.

    Planning captures immutable source-state evidence. Execution uses that same
    plan and fails closed with SourceStateChangedSincePlanning if the source no
    longer matches the approved state before mutation. Verification compares the
    destination with the approved/copied source evidence rather than rereading a
    moving source branch or ref set.

    An existing destination is never silently overwritten. Replacement requires
    an explicit archive plan and exact confirmation, and the prior repository is
    preserved before a fresh replacement is created. -Force does not bypass exact
    replacement confirmation.

    After content verification, supported repository settings are restored and
    verified, followed by transferable repository protection. -SkipSettings skips
    both configuration stages. Protection that cannot be transferred safely is
    reported rather than weakened.

    Mutating execution supports -WhatIf and -Confirm. Non-interactive mutation
    requires -Force. Changing destination visibility requires -Force. GitHub Pages
    restoration and Actions activation are reserved switches and are not yet
    implemented for mutating execution. Version 1 supports github.com only.

    .PARAMETER SourceRepository
    Specifies the source repository as owner/name. The source must exist and have
    a default branch with content. Planning captures immutable source Git state.

    .PARAMETER DestinationRepository
    Specifies the destination as owner/name. A different existing destination is
    never overwritten; use -ExistingDestinationArchiveName to archive and replace
    it explicitly. The same owner/name selects same-name replacement.

    .PARAMETER ContentMode
    Selects Snapshot or FullHistory. Snapshot is the default clean-publication
    mode. FullHistory preserves approved Git history, branches, tags, and reachable
    Git LFS objects.

    .PARAMETER DestinationVisibility
    Specifies public, private, or internal destination visibility. If omitted,
    source visibility is preserved. A deliberate visibility change requires -Force.

    .PARAMETER ArchiveRepositoryName
    Specifies the archive repository name for same-name replacement. The archive
    must not already exist.

    .PARAMETER SameNameConfirmation
    Supplies the exact case-sensitive confirmation for same-name replacement in
    SOURCE=owner/source;ARCHIVE=owner/archive;REPLACEMENT=owner/source form.
    -Force and -Confirm:$false do not bypass this requirement.

    .PARAMETER ExistingDestinationArchiveName
    Specifies the archive repository name used to preserve an already-existing
    different destination before creating its replacement.

    .PARAMETER ExistingDestinationConfirmation
    Supplies the exact case-sensitive confirmation for an existing-destination
    replacement in DESTINATION=owner/destination;ARCHIVE=owner/archive;REPLACEMENT=owner/destination form.
    -Force and -Confirm:$false do not bypass this requirement.

    .PARAMETER CommitMessage
    Specifies the Snapshot root commit message. The default is 'Initial repository commit'.
    FullHistory preserves existing commits and does not rewrite them.

    .PARAMETER RestorePages
    Requests GitHub Pages restoration. Planning records the request, but mutating
    execution rejects it because Pages restoration is not yet implemented.

    .PARAMETER EnableActionsAfterMigration
    Requests Actions activation after the copy. Planning records the request, but
    mutating execution rejects it because this behavior is not yet implemented.

    .PARAMETER SkipSettings
    Skips restoration of supported repository settings and repository protection.
    Content verification still runs.

    .PARAMETER PlanOnly
    Returns a read-only repository copy plan without mutation. The plan contains
    immutable source-state evidence used to bind later review and execution.

    .PARAMETER NonInteractive
    Prevents interactive confirmation prompts. Non-interactive mutation requires
    -Force, and replacement modes still require exact confirmation values.

    .PARAMETER OutputMode
    Selects PlanOnly output: Interactive returns the structured plan, Plain returns
    Markdown, and Json returns JSON. Execution always returns structured results.

    .PARAMETER ReportPath
    Writes the plan or execution report to the selected path. Recovery reporting
    uses this as the preferred path when mutation has begun.

    .PARAMETER HostName
    Specifies the GitHub host. The default is github.com. Version 1 supports
    github.com only.

    .PARAMETER Force
    Acknowledges non-interactive mutation and deliberate visibility changes and
    suppresses routine confirmation prompts. Force does not bypass exact archive
    and replacement confirmation.

    .EXAMPLE
    Copy-GitHubRepository -SourceRepository infoconex/source -DestinationRepository infoconex/destination -PlanOnly

    Creates a read-only Snapshot repository copy plan with immutable source commit
    and tree evidence.

    .EXAMPLE
    Copy-GitHubRepository -SourceRepository infoconex/source -DestinationRepository infoconex/destination

    Publishes the approved Snapshot source state as one new unrelated root commit,
    verifies it, and restores supported configuration.

    .EXAMPLE
    Copy-GitHubRepository -SourceRepository infoconex/source -DestinationRepository infoconex/destination -ContentMode FullHistory

    Copies and verifies the approved history-preserving branch/tag/ref state.

    .INPUTS
    None. This command does not accept pipeline input.

    .OUTPUTS
    CopyGitHubRepo.MigrationPlan for structured planning, System.String for Plain
    or Json plan output, and CopyGitHubRepo.MigrationExecutionResult for execution.

    .LINK
    https://github.com/infoconex/copy-github-repo

    .LINK
    https://github.com/infoconex/copy-github-repo/blob/main/docs/product/product-contract.md
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory)]
        [ValidatePattern('^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$')]
        [string] $SourceRepository,

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

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

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

        [ValidatePattern('^[A-Za-z0-9_.-]+$')]
        [string] $ArchiveRepositoryName,

        [string] $SameNameConfirmation,

        [ValidatePattern('^[A-Za-z0-9_.-]+$')]
        [string] $ExistingDestinationArchiveName,

        [string] $ExistingDestinationConfirmation,

        [ValidateNotNullOrEmpty()]
        [string] $CommitMessage = 'Initial repository commit',

        [switch] $RestorePages,

        [switch] $EnableActionsAfterMigration,

        [switch] $SkipSettings,

        [switch] $PlanOnly,

        [switch] $NonInteractive,

        [ValidateSet('Interactive', 'Plain', 'Json')]
        [string] $OutputMode = 'Interactive',

        [string] $ReportPath,

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

        [switch] $Force
    )

    Assert-CgrSupportedHostName -HostName $HostName

    $prerequisites = Get-CgrPrerequisiteStatus -HostName $HostName
    if (-not $prerequisites.Git.Found) {
        $exception = [System.InvalidOperationException]::new("Git is required for repository copy planning. Install 'git' from https://git-scm.com/.")
        $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, 'GitNotFound', [System.Management.Automation.ErrorCategory]::ResourceUnavailable, 'git')
        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }
    if (-not $prerequisites.GitHubCli.Found) {
        $exception = [System.InvalidOperationException]::new("GitHub CLI is required for repository copy planning. Install 'gh' from https://cli.github.com/.")
        $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, 'GitHubCliNotFound', [System.Management.Automation.ErrorCategory]::ResourceUnavailable, 'gh')
        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }
    if (-not $prerequisites.Authentication.Authenticated) {
        $exception = [System.InvalidOperationException]::new($prerequisites.Authentication.Message)
        $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, 'GitHubCliNotAuthenticated', [System.Management.Automation.ErrorCategory]::SecurityError, $HostName)
        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }

    $source = Get-CgrRepository -Repository $SourceRepository -HostName $HostName
    $destinationVisibilityWasProvided = $PSBoundParameters.ContainsKey('DestinationVisibility')
    $resolvedDestinationVisibility = if ($destinationVisibilityWasProvided) { $DestinationVisibility } else { $source.Visibility }

    $plan = New-CgrMigrationPlan `
        -SourceRepository $source `
        -DestinationRepository $DestinationRepository `
        -ContentMode $ContentMode `
        -DestinationVisibility $resolvedDestinationVisibility `
        -ArchiveRepositoryName $ArchiveRepositoryName `
        -ExistingDestinationArchiveName $ExistingDestinationArchiveName `
        -CommitMessage $CommitMessage `
        -RestorePages:$RestorePages `
        -EnableActionsAfterMigration:$EnableActionsAfterMigration `
        -SkipSettings:$SkipSettings `
        -HostName $HostName `
        -PlanOnly:$PlanOnly

    if ($PlanOnly) {
        if ($ReportPath) { Write-CgrMigrationPlanReport -Plan $plan -Path $ReportPath }
        if ($OutputMode -eq 'Json') { return Format-CgrMigrationPlan -Plan $plan -Format Json }
        if ($OutputMode -eq 'Plain') { return Format-CgrMigrationPlan -Plan $plan -Format Markdown }
        return $plan
    }

    if ($RestorePages) {
        $message = 'Pages restoration is not implemented yet. Remove -RestorePages and review the plan output for unsupported settings.'
        $exception = [System.NotSupportedException]::new($message)
        $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, 'RestorePagesExecutionNotImplemented', [System.Management.Automation.ErrorCategory]::NotImplemented, 'RestorePages')
        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }
    if ($EnableActionsAfterMigration) {
        $message = 'Actions activation after repository copy is not implemented yet. Remove -EnableActionsAfterMigration and review the plan output.'
        $exception = [System.NotSupportedException]::new($message)
        $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, 'EnableActionsAfterMigrationExecutionNotImplemented', [System.Management.Automation.ErrorCategory]::NotImplemented, 'EnableActionsAfterMigration')
        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }
    if ($NonInteractive -and -not $Force) {
        $message = 'Non-interactive mutation requires -Force for repository copies.'
        $exception = [System.InvalidOperationException]::new($message)
        $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, 'NonInteractiveExecutionRequiresForce', [System.Management.Automation.ErrorCategory]::InvalidOperation, $DestinationRepository)
        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }

    $isVisibilityChange = $plan.DestinationVisibility -ne $source.Visibility
    if ($isVisibilityChange -and -not $Force) {
        $message = "Changing '$($source.FullName)' from '$($source.Visibility)' to '$($plan.DestinationVisibility)' visibility requires explicit acknowledgement. Rerun with -Force only when this visibility change is intentional."
        $exception = [System.InvalidOperationException]::new($message)
        $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, 'VisibilityChangeRequiresForce', [System.Management.Automation.ErrorCategory]::PermissionDenied, $DestinationRepository)
        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }

    if ($Force) { $ConfirmPreference = 'None' }

    $target = $plan.DestinationRepository
    $action = switch ($plan.Mode) {
        'SameNameReplacement' {
            if ($plan.ContentMode -eq 'FullHistory') {
                "Preserve '$($plan.SourceRepository)' as '$($plan.ArchiveRepository)', create '$target', and copy the approved FullHistory state"
            } else {
                "Preserve '$($plan.SourceRepository)' as '$($plan.ArchiveRepository)', create '$target', and publish the approved Snapshot state"
            }
        }
        'ExistingDestinationReplacement' {
            "Preserve existing destination '$target' as '$($plan.ArchiveRepository)', create a fresh replacement, and copy the approved $($plan.ContentMode) state"
        }
        default {
            if ($plan.ContentMode -eq 'FullHistory') {
                "Create '$target' and copy the approved FullHistory state"
            } else {
                "Create '$target' and publish the approved Snapshot state"
            }
        }
    }

    if (-not $PSCmdlet.ShouldProcess($target, $action)) { return $plan }

    $resolvedSameNameConfirmation = $SameNameConfirmation
    $resolvedExistingDestinationConfirmation = $ExistingDestinationConfirmation
    $confirmWasExplicitlyDisabled = $PSBoundParameters.ContainsKey('Confirm') -and -not [bool] $PSBoundParameters['Confirm']

    if ($plan.Mode -eq 'SameNameReplacement' -and
        [string]::IsNullOrWhiteSpace($resolvedSameNameConfirmation) -and
        -not $NonInteractive -and
        -not $confirmWasExplicitlyDisabled) {
        $expectedConfirmation = "SOURCE=$($plan.SourceRepository);ARCHIVE=$($plan.ArchiveRepository);REPLACEMENT=$($plan.DestinationRepository)"
        $resolvedSameNameConfirmation = Read-Host "Type exactly: $expectedConfirmation"
    }

    if ($plan.Mode -eq 'ExistingDestinationReplacement' -and
        [string]::IsNullOrWhiteSpace($resolvedExistingDestinationConfirmation) -and
        -not $NonInteractive -and
        -not $confirmWasExplicitlyDisabled) {
        $expectedConfirmation = "DESTINATION=$($plan.DestinationRepository);ARCHIVE=$($plan.ArchiveRepository);REPLACEMENT=$($plan.DestinationRepository)"
        $resolvedExistingDestinationConfirmation = Read-Host "Type exactly: $expectedConfirmation"
    }

    return Invoke-CgrApprovedMigrationPlan `
        -Plan $plan `
        -SourceRepository $source `
        -SameNameConfirmation $resolvedSameNameConfirmation `
        -ExistingDestinationConfirmation $resolvedExistingDestinationConfirmation `
        -HostName $HostName `
        -ReportPath $ReportPath
}