RepoHerd.ps1

#Requires -Version 7.6
<#
.SYNOPSIS
    RepoHerd - Checks out a collection of Git repositories to specified tags
.DESCRIPTION
    This script reads a JSON configuration file and checks out multiple Git repositories
    to their specified tags. It supports both HTTPS and SSH URLs, handles Git LFS,
    initializes submodules, and provides comprehensive error handling and logging.

    SSH credentials are managed through a separate git_credentials.json file.

    With the -DisableRecursion option, it processes only the main dependency file.

    The script uses intelligent tag temporal sorting based on actual git tag dates,
    eliminating the need for manual temporal ordering in "API Compatible Tags".

    Post-checkout PowerShell scripts can be executed after successful repository
    checkouts to integrate with external dependency management systems. Scripts
    can be configured at any depth level, including depth 0 (root level).
.PARAMETER InputFile
    Path to the JSON configuration file. Defaults to 'dependencies.json' in the script directory.
.PARAMETER CredentialsFile
    Path to the SSH credentials JSON file. Defaults to 'git_credentials.json' in the script directory.
.PARAMETER DryRun
    If specified, shows what would be done without actually executing Git commands.
.PARAMETER EnableDebug
    Enables debug logging to a timestamped log file.
.PARAMETER Verbose
    Increases verbosity of output messages.
.PARAMETER DisableRecursion
    Disables recursive dependency discovery and processing. By default, recursive mode is enabled.
.PARAMETER MaxDepth
    Maximum recursion depth for dependency discovery. Defaults to 5.
.PARAMETER ApiCompatibility
    Default API compatibility mode when not specified in dependencies. Can be 'Strict' or 'Permissive'. Defaults to 'Permissive'.
.PARAMETER DisablePostCheckoutScripts
    Disables execution of post-checkout PowerShell scripts. By default, post-checkout scripts are enabled.
.PARAMETER EnableErrorContext
    Enables detailed error context output including stack traces and line numbers.
    By default, only simple error messages are shown. Use this for advanced debugging.
.EXAMPLE
    .\RepoHerd.ps1
    .\RepoHerd.ps1 -InputFile "C:\configs\myrepos.json" -CredentialsFile "C:\configs\my_credentials.json"
    .\RepoHerd.ps1 -DisableRecursion -MaxDepth 10
    .\RepoHerd.ps1 -InputFile "repos.json" -EnableDebug -ApiCompatibility Strict
    .\RepoHerd.ps1 -Verbose -DisablePostCheckoutScripts
    .\RepoHerd.ps1 -EnableDebug -EnableErrorContext
.NOTES
    Version: 9.0.0
    Last Modified: 2026-03-20

    Requires PowerShell 7.6 LTS or later (installs side-by-side with Windows PowerShell 5.1).
    Install via: winget install Microsoft.PowerShell

    SSH authentication is cross-platform: PuTTY/plink with .ppk keys on Windows,
    OpenSSH on macOS/Linux. Use PuTTYgen to convert OpenSSH keys to .ppk format on Windows.
#>


[CmdletBinding()]
param(
    [Parameter(Position = 0)]
    [string]$InputFile,

    [Parameter()]
    [string]$CredentialsFile,

    [Parameter()]
    [switch]$DryRun,

    [Parameter()]
    [switch]$EnableDebug,

    [Parameter()]
    [switch]$DisableRecursion,

    [Parameter()]
    [int]$MaxDepth = 5,

    [Parameter()]
    [ValidateSet('Strict', 'Permissive')]
    [string]$ApiCompatibility = 'Permissive',

    [Parameter()]
    [switch]$DisablePostCheckoutScripts,

    [Parameter()]
    [switch]$EnableErrorContext,

    [Parameter()]
    [string]$OutputFile
)

# Import module from same directory as this script
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Import-Module (Join-Path $scriptDir 'RepoHerd.psm1') -Force

# Initialize module state from script parameters
Initialize-RepoHerd `
    -ScriptPath $scriptDir `
    -DryRun:$DryRun `
    -EnableDebug:$EnableDebug `
    -DisableRecursion:$DisableRecursion `
    -MaxDepth $MaxDepth `
    -ApiCompatibility $ApiCompatibility `
    -DisablePostCheckoutScripts:$DisablePostCheckoutScripts `
    -EnableErrorContext:$EnableErrorContext `
    -OutputFile $OutputFile

# Main execution
$exitCode = 0
try {
    Write-Log "RepoHerd started - Version 9.0.0" -Level Info
    Write-Log "Script path: $scriptDir" -Level Debug
    Write-Log "PowerShell version: $($PSVersionTable.PSVersion)" -Level Debug
    Write-Log "Operating System: $([System.Environment]::OSVersion.VersionString)" -Level Debug
    Write-Log "Default API Compatibility: $ApiCompatibility" -Level Info

    if (-not $DisableRecursion) {
        Write-Log "Recursive mode: ENABLED (default) with max depth: $MaxDepth" -Level Info
    } else {
        Write-Log "Recursive mode: DISABLED" -Level Info
    }

    if (-not $DisablePostCheckoutScripts) {
        Write-Log "Post-checkout scripts: ENABLED (default)" -Level Info
    } else {
        Write-Log "Post-checkout scripts: DISABLED" -Level Info
    }

    if ($EnableErrorContext) {
        Write-Log "Error context: ENABLED - Detailed error information will be shown" -Level Info
    } else {
        Write-Log "Error context: DISABLED - Use -EnableErrorContext for detailed error information" -Level Debug
    }

    if ($OutputFile) {
        Write-Log "Structured output will be written to: $OutputFile" -Level Info
    }

    # Calculate and log script hash in debug mode
    if ($EnableDebug) {
        $scriptContent = Get-Content -Path $MyInvocation.MyCommand.Path -Raw
        $scriptBytes = [System.Text.Encoding]::UTF8.GetBytes($scriptContent)
        $sha256 = [System.Security.Cryptography.SHA256]::Create()
        $hashBytes = $sha256.ComputeHash($scriptBytes)
        $scriptHash = [System.BitConverter]::ToString($hashBytes).Replace('-', '')
        Write-Log "Script SHA256 hash: $($scriptHash.Substring(0, 16))..." -Level Debug
        Write-Log "Full script hash: $scriptHash" -Level Debug
    }

    if ($DryRun) {
        Write-Log "DRY RUN MODE - No actual changes will be made" -Level Warning
    }

    if ($EnableDebug) {
        Write-Log "Debug logging enabled" -Level Info
    }

    # Check Git installation
    if (-not (Test-GitInstalled)) {
        throw "Git is not installed or not accessible in PATH"
    }

    # Determine input file path
    if ([string]::IsNullOrEmpty($InputFile)) {
        $InputFile = Join-Path $scriptDir "dependencies.json"
        Write-Log "Using default input file: $InputFile" -Level Verbose
    }

    # Store the dependency file name for recursive processing
    # Access module internals for setting the default dependency file name
    & (Get-Module RepoHerd) { $script:DefaultDependencyFileName = $args[0] } (Split-Path -Leaf $InputFile)
    Write-Log "Default dependency file name for recursive processing: $(Split-Path -Leaf $InputFile)" -Level Debug

    # Determine credentials file path
    if ([string]::IsNullOrEmpty($CredentialsFile)) {
        $CredentialsFile = Join-Path $scriptDir "git_credentials.json"
        Write-Log "Using default credentials file: $CredentialsFile" -Level Verbose
    }

    # Read SSH credentials
    $sshCreds = Read-CredentialsFile -FilePath $CredentialsFile
    & (Get-Module RepoHerd) { $script:SshCredentials = $args[0] } $sshCreds

    # Check if input file exists
    if (-not (Test-Path $InputFile)) {
        throw "Input file not found: $InputFile"
    }

    # Process the initial dependency file with enhanced error handling
    Write-Log "Starting dependency processing at depth 0" -Level Info

    $checkedOutRepos = Invoke-WithErrorContext -Context "Processing root dependency file" -ScriptBlock {
        Invoke-DependencyFile -DependencyFilePath $InputFile -Depth 0
    }

    # Handle null return
    if ($null -eq $checkedOutRepos) {
        Write-Log "WARNING: Invoke-DependencyFile returned null, initializing as empty array" -Level Warning
        $checkedOutRepos = @()
    } else {
        Write-Log "Invoke-DependencyFile returned type: $($checkedOutRepos.GetType().FullName)" -Level Debug
    }
    if ($null -eq $checkedOutRepos) {
        Write-Log "WARNING: checkedOutRepos is null!" -Level Warning
        $checkedOutRepos = @()
    }

    Write-Log "Completed depth 0 processing: 1 dependency file processed, $($checkedOutRepos.Count) repositories checked out" -Level Info

    # Additional debug information
    if ($EnableDebug) {
        Write-Log "Detailed checkedOutRepos information:" -Level Debug
        Write-Log " Count: $($checkedOutRepos.Count)" -Level Debug
        Write-Log " IsArray: $($checkedOutRepos -is [Array])" -Level Debug
        if ($checkedOutRepos.Count -gt 0) {
            Write-Log " Repository details:" -Level Debug
            foreach ($repo in $checkedOutRepos) {
                Write-Log " - Repository: $($repo.Repository.'Repository URL'), Path: $($repo.AbsolutePath)" -Level Debug
            }
        }
    }

    # If recursive mode is enabled, process nested dependencies
    $isRecursiveMode = -not $DisableRecursion
    Write-Log "Checking recursive processing conditions - RecursiveMode: $isRecursiveMode, CheckedOutRepos.Count: $($checkedOutRepos.Count)" -Level Debug

    if ($isRecursiveMode -and $checkedOutRepos.Count -gt 0) {
        Write-Log "Entering recursive processing with $($checkedOutRepos.Count) repositories" -Level Info
        $defaultDepFileName = Split-Path -Leaf $InputFile
        Invoke-WithErrorContext -Context "Processing recursive dependencies" -ScriptBlock {
            Invoke-RecursiveDependencies -CheckedOutRepos $checkedOutRepos -DefaultDependencyFileName $defaultDepFileName -CurrentDepth 0
        }
    } else {
        if ($DisableRecursion) {
            Write-Log "Recursive processing skipped - recursive mode is disabled" -Level Info
        } elseif ($checkedOutRepos.Count -eq 0) {
            Write-Log "Recursive processing skipped - no new repositories were checked out at depth 0" -Level Info
        }
    }

    # Show summary
    Show-Summary

    # Determine exit code from failure count
    $failureCount = & (Get-Module RepoHerd) { $script:FailureCount }
    if ($failureCount -gt 0) {
        $exitCode = 1
    }
}
catch {
    Write-ErrorWithContext -ErrorRecord $_ -AdditionalMessage "Unexpected error in main execution"
    Show-ErrorDialog -Message $_.Exception.Message
    $exitCode = 1
}
finally {
    # Write structured output if requested — guaranteed even on failure
    if (-not [string]::IsNullOrEmpty($OutputFile)) {
        try {
            Export-CheckoutResults -OutputFile $OutputFile
        }
        catch {
            Write-Host "Failed to write output file: $_" -ForegroundColor Red
        }
    }
}

exit $exitCode