Private/BranchHelpers.psm1

<#
.SYNOPSIS
    Internal helpers for per-mapping branch resolution.
.DESCRIPTION
    Each source mapping may target a specific Git branch (e.g. $/Proj/DEV -> dev,
    $/Proj/Prod -> main). These helpers read that branch safely from either a
    hashtable (built by New-TfvcMigrationConfig) or a PSCustomObject (parsed from
    config.json), defaulting to 'main' when unspecified. Not exported.
#>


function Get-MappingBranch {
    <#
    .SYNOPSIS
        Returns the target Git branch for a source mapping (default 'main').
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Mapping
    )

    $branch = $null
    if ($Mapping -is [hashtable]) {
        if ($Mapping.ContainsKey('branch')) { $branch = $Mapping['branch'] }
    }
    elseif ($null -ne $Mapping -and $null -ne $Mapping.PSObject.Properties['branch']) {
        $branch = $Mapping.branch
    }

    if ([string]::IsNullOrWhiteSpace("$branch")) { return 'main' }
    return "$branch".Trim()
}

function Get-MappingParentBranch {
    <#
    .SYNOPSIS
        Returns the parent Git branch for a source mapping (default empty).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Mapping
    )

    $parent = $null
    if ($Mapping -is [hashtable]) {
        if ($Mapping.ContainsKey('gitParentBranch')) { $parent = $Mapping['gitParentBranch'] }
    }
    elseif ($null -ne $Mapping -and $null -ne $Mapping.PSObject.Properties['gitParentBranch']) {
        $parent = $Mapping.gitParentBranch
    }

    if ([string]::IsNullOrWhiteSpace("$parent")) { return '' }
    return "$parent".Trim()
}

function Get-ConfigBranches {
    <#
    .SYNOPSIS
        Returns the distinct list of target branches across all source mappings.
        Topologically sorted so that parent branches appear before their children.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$SourceMappings
    )

    $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $deps = @{}
    
    foreach ($m in $SourceMappings) {
        $b = Get-MappingBranch -Mapping $m
        $p = Get-MappingParentBranch -Mapping $m
        if (-not $seen.Contains($b)) {
            [void]$seen.Add($b)
            $deps[$b] = $p
        }
    }

    $sorted = [System.Collections.Generic.List[string]]::new()
    $visited = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $visiting = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

    function Visit([string]$branch) {
        if ($visited.Contains($branch)) { return }
        if ($visiting.Contains($branch)) { throw "Circular branch dependency detected for '$branch'." }
        
        [void]$visiting.Add($branch)
        $parent = $deps[$branch]
        if (-not [string]::IsNullOrEmpty($parent) -and $seen.Contains($parent)) {
            Visit $parent
        }
        [void]$visiting.Remove($branch)
        [void]$visited.Add($branch)
        $sorted.Add($branch)
    }

    foreach ($b in $seen) {
        Visit $b
    }

    return ,@($sorted)
}

function Get-PrimaryBranch {
    <#
    .SYNOPSIS
        The branch the repo should be left on (and the repo's natural default):
        'main' if any mapping targets it, otherwise the first branch.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$SourceMappings
    )

    $branches = Get-ConfigBranches -SourceMappings $SourceMappings
    if ($branches -contains 'main') { return 'main' }
    if ($branches.Count -gt 0) { return $branches[0] }
    return 'main'
}