Engines/ManagedFiles/Sync-AvmManagedFile.ps1

function Sync-AvmManagedFile {
    <#
    .SYNOPSIS
        Synchronise a Terraform module repository's managed files against the
        AVM governance source-of-truth, applying additions, updates, and
        deprecated-file removals directly to the working tree.

    .DESCRIPTION
        Engine implementation behind Invoke-AvmSync, reduced to the *file-sync*
        concern only: it never opens or merges a pull request, it mutates the
        local working tree ($Context.Root) in place.

        Source of the managed files (highest precedence first):

          1. Explicit cmdlet parameters.
          2. Environment variables (AVM_MANAGED_FILES_*).
          3. A repo-committed '.avm/managed-files.json' under $Context.Root.
          4. Defaults: files from Azure/azure-verified-modules-managed-files
             ('main' ref, 'terraform/files' base folder, file-group config
             'terraform/config/managed-files.json') and config.json from
             Azure/azure-verified-modules-tools ('main' ref,
             'repository-management/repository-config' folder).

        A direct local path (-ManagedFilesLocalPath or
        AVM_MANAGED_FILES_LOCAL_PATH) short-circuits the git fetch entirely and
        is what the offline tests use. Otherwise the source repo is shallow
        cloned (or fetched, if already cached) into $env:AVM_HOME/cache.

        The managed-file map is built from '<base>/root' plus zero or more
        overlays ('<base>/<overlay>') stacked in declaration order, where later
        sources win, minus any excluded paths. A source subtree at
        '<parent>/_all/' is broadcast into every existing immediate child of
        the matching target parent; reserved '_all' segments are never copied
        literally. A root-level '_all/' remains a literal path. Overlays and
        exclusions are resolved from the config folder's 'config.json' by
        matching the repository id against 'repositoryGroups'.
        'deprecated-files.json' lists paths that must be removed from every
        target repo; deprecated removals win over managed adds when both name
        the same path.

        For each desired managed file the engine computes git's blob SHA-1 over
        the source bytes and compares it (plus the git index mode) with the
        on-disk state under $Context.Root:

          - Add = desired path absent on disk.
          - Update = desired path present but blob SHA or mode differs.
          - Remove = deprecated path present on disk (file or directory).

        With -CheckDrift the engine writes nothing: any needed change makes the
        aggregate Status 'fail' and is recorded as an Issue (the pr-check hard
        gate). Otherwise it applies the changes (honouring -WhatIf /
        SupportsShouldProcess) and returns Status 'pass'.

    .PARAMETER Context
        Module context produced by Get-AvmModuleContext. Must have
        Ecosystem='terraform'. Its Root is the working tree that is synced.

    .PARAMETER AllowPathFallback
        Accepted for signature parity with the other engines. The managed-files
        engine shells out to plain 'git' (not a pinned AVM tool), so this switch
        is currently a no-op.

    .PARAMETER CheckDrift
        Report-only mode. No files are written; any required change flips the
        Status to 'fail' and is emitted as an Issue.

    .PARAMETER ManagedFilesRepo
        owner/name of the git repo that holds the managed files. Defaults to
        'Azure/azure-verified-modules-tools'.

    .PARAMETER ManagedFilesRef
        Git ref (branch/tag/sha) to fetch. Defaults to 'main'.

    .PARAMETER ManagedFilesPath
    .PARAMETER ManagedFilesPath
        Path within the source repo to the managed-files base folder (the one
        that contains the file group folders). Defaults to 'terraform/files'.

    .PARAMETER ManagedFilesLocalPath
        Direct local path to the managed-files base folder. When supplied the
        git fetch is skipped entirely.

    .PARAMETER FileGroupConfigPath
        Path within the managed-files repo to the file-group config that
        declares each group's deleted files. Defaults to
        'terraform/config/managed-files.json'.

    .PARAMETER FileGroupConfigLocalPath
        Direct local path to the file-group config file. When omitted with a
        local managed-files path, it is looked up alongside the files folder.

    .PARAMETER ConfigRepo
        owner/name of the git repo that holds the config folder. Defaults to
        'Azure/azure-verified-modules-tools'.

    .PARAMETER ConfigRef
        Git ref for the config repo. Defaults to 'main'.

    .PARAMETER ConfigPath
        Path within the config repo to the folder holding 'config.json'.
        Defaults to 'repository-management/repository-config'.

    .PARAMETER ConfigLocalPath
        Direct local path to the config folder. When supplied no config repo is
        fetched.

    .PARAMETER RepoId
        The repository id used to look up file groups in config.json. When
        omitted it is resolved by Resolve-AvmManagedFilesRepoId: an explicit
        AVM_MANAGED_FILES_REPO_ID environment value or '.avm/managed-files.json'
        repoId override is authoritative; otherwise a candidate is derived from
        the git origin remote, then the working-tree folder name, with a leading
        'terraform-azurerm-' / 'terraform-azapi-' prefix stripped. Matching a
        config.json repositoryGroups entry adds that group's file groups; every
        repository matches the 'default' group and so receives the shared root
        files. Resolution fails only when no repository id can be determined.

    .OUTPUTS
        pscustomobject with Engine, Tool, ToolPath, ToolSource, Status,
        FilesProcessed, Issues, Added, Updated, Removed.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSReviewUnusedParameter', 'AllowPathFallback',
        Justification = 'Accepted for cross-verb parity and forwarded by Invoke-AvmSync; this engine shells out to plain git (Get-Command) rather than an AVM-pinned tool, so there is no resolved tool path to fall back on.')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        $Context,

        [switch] $AllowPathFallback,

        [switch] $CheckDrift,

        [string] $ManagedFilesRepo,
        [string] $ManagedFilesRef,
        [string] $ManagedFilesPath,
        [string] $ManagedFilesLocalPath,

        [string] $FileGroupConfigPath,
        [string] $FileGroupConfigLocalPath,

        [string] $ConfigRepo,
        [string] $ConfigRef,
        [string] $ConfigPath,
        [string] $ConfigLocalPath,

        [string] $RepoId
    )

    Set-StrictMode -Version 3.0
    $ErrorActionPreference = 'Stop'

    if ($Context.Ecosystem -ne 'terraform') {
        throw [System.ArgumentException]::new(
            "Sync-AvmManagedFile requires a terraform context (got Ecosystem='$($Context.Ecosystem)').")
    }

    $root = $Context.Root

    $settings = Resolve-AvmManagedFilesSetting `
        -Root $root `
        -ManagedFilesRepo $ManagedFilesRepo `
        -ManagedFilesRef $ManagedFilesRef `
        -ManagedFilesPath $ManagedFilesPath `
        -ManagedFilesLocalPath $ManagedFilesLocalPath `
        -FileGroupConfigPath $FileGroupConfigPath `
        -FileGroupConfigLocalPath $FileGroupConfigLocalPath `
        -ConfigRepo $ConfigRepo `
        -ConfigRef $ConfigRef `
        -ConfigPath $ConfigPath `
        -ConfigLocalPath $ConfigLocalPath `
        -RepoId $RepoId

    $gitPath = (Get-Command -Name 'git' -CommandType Application -ErrorAction SilentlyContinue |
            Select-Object -First 1).Source

    $source = Resolve-AvmManagedFilesSource -Settings $settings -GitPath $gitPath
    Write-AvmLog ("sync: source kind={0}; managed-files={1}; config={2}" -f $source.SourceKind, $source.ManagedBaseDir, $source.ConfigDir) -Level Verbose | Out-Null

    $fileGroups = @()
    $deletedFilesByGroup = @{}
    $repositoryConfig = $null
    if ($source.ConfigDir -and (Test-Path -LiteralPath $source.ConfigDir -PathType Container)) {
        $configFile = Join-Path $source.ConfigDir 'config.json'
        if (Test-Path -LiteralPath $configFile -PathType Leaf) {
            $repositoryConfig = Get-Content -LiteralPath $configFile -Raw | ConvertFrom-Json
        }
    }

    if ($source.FileGroupConfigFile -and (Test-Path -LiteralPath $source.FileGroupConfigFile -PathType Leaf)) {
        $deletedFilesByGroup = Get-AvmManagedFilesDeletedFileMap -Path $source.FileGroupConfigFile
    }

    $repoId = Resolve-AvmManagedFilesRepoId `
        -Root $root `
        -ExplicitRepoId $settings.RepoId `
        -KnownRepoIds (Get-AvmManagedFilesKnownRepoId -RepositoryConfig $repositoryConfig) `
        -GitPath $gitPath `
        -Interactive (Test-AvmManagedFilesInteractive)

    if ($repositoryConfig) {
        $fileGroups = (Resolve-AvmManagedFilesRepositorySetting -RepositoryConfig $repositoryConfig -RepoId $repoId).FileGroups
    }
    Write-AvmLog ("sync: repo-id={0}; file-groups={1}" -f $repoId, ($fileGroups -join ', ')) -Level Verbose | Out-Null

    $managed = Build-AvmManagedFilesMap `
        -BaseDir $source.ManagedBaseDir `
        -TargetRoot $root `
        -FileGroups $fileGroups `
        -DeletedFilesByGroup $deletedFilesByGroup `
        -RepoId $repoId `
        -GitPath $gitPath

    $desired = Get-AvmDesiredManagedFile -ManagedFiles $managed.Files

    # Deleted files win over managed adds if both name the same path.
    $matchedDeleted = @(Get-AvmMatchingDeprecatedPath -CandidatePaths $managed.Deleted -Root $root)
    $deletedLookup = @{}
    foreach ($p in $matchedDeleted) { $deletedLookup[$p] = $true }
    foreach ($p in @($desired.Keys)) {
        if ($deletedLookup.ContainsKey($p)) { $desired.Remove($p) | Out-Null }
    }
    Write-AvmLog ("sync: desired files={0}; matched deleted files={1}" -f $desired.Count, $matchedDeleted.Count) -Level Verbose | Out-Null

    # Line-managed files (e.g. .gitignore) are merged line-by-line rather than
    # overwritten wholesale, so the consumer keeps its own additions. The spec
    # stacks across file groups like the files themselves. A path owned by the
    # line spec must not also be whole-file managed (line-merge wins), and a
    # deletion still trumps a line merge.
    $lineSpec = Get-AvmManagedLineSpec -BaseDir $source.ManagedBaseDir -FileGroups $fileGroups
    foreach ($p in @($lineSpec.Keys)) {
        if ($deletedLookup.ContainsKey($p)) { $lineSpec.Remove($p) | Out-Null }
    }
    foreach ($p in @($lineSpec.Keys)) {
        if ($desired.ContainsKey($p)) { $desired.Remove($p) | Out-Null }
    }
    $linePlans = @(Get-AvmManagedLinePlan -Root $root -Spec $lineSpec)
    $changedLinePlans = @($linePlans | Where-Object { $_.Changed })

    $targetModes = Get-AvmGitIndexMode -Dir $root -GitPath $gitPath
    $existingBlobs = @{}
    $existingModes = @{}
    foreach ($targetPath in $desired.Keys) {
        $full = Join-Path $root ($targetPath.Replace('/', [System.IO.Path]::DirectorySeparatorChar))
        if (Test-Path -LiteralPath $full -PathType Leaf) {
            $bytes = [System.IO.File]::ReadAllBytes($full)
            $existingBlobs[$targetPath] = Get-AvmGitBlobSha -Bytes $bytes
            $mode = $targetModes[$targetPath]
            if (-not $mode) { $mode = '100644' }
            $existingModes[$targetPath] = $mode
        }
    }

    $toAdd = @()
    $toUpdate = @()
    $updateReasons = @{}
    foreach ($targetPath in ($desired.Keys | Sort-Object)) {
        $desiredSha = $desired[$targetPath].Sha
        $desiredMode = $desired[$targetPath].Mode
        if (-not $existingBlobs.ContainsKey($targetPath)) {
            $toAdd += $targetPath
        }
        else {
            $existingSha = $existingBlobs[$targetPath]
            $existingMode = $existingModes[$targetPath]
            if (-not $existingMode) { $existingMode = '100644' }
            $contentChanged = $existingSha -ne $desiredSha
            $modeChanged = $existingMode -ne $desiredMode
            if ($contentChanged -or $modeChanged) {
                $toUpdate += $targetPath
                $updateReasons[$targetPath] = if ($contentChanged -and $modeChanged) {
                    "content and mode $existingMode -> $desiredMode"
                }
                elseif ($contentChanged) {
                    'content'
                }
                else {
                    "mode $existingMode -> $desiredMode"
                }
            }
        }
    }
    $toRemove = @($matchedDeleted | Sort-Object)

    $lineAdded = @($changedLinePlans | Where-Object { -not $_.Existed } | ForEach-Object { $_.Path } | Sort-Object)
    $lineUpdated = @($changedLinePlans | Where-Object { $_.Existed } | ForEach-Object { $_.Path } | Sort-Object)
    Write-AvmLog ("sync: plan add={0}; update={1}; remove={2}; line-merge={3}" -f $toAdd.Count, $toUpdate.Count, $toRemove.Count, $changedLinePlans.Count) -Level Verbose | Out-Null
    foreach ($p in @(@($toAdd + $lineAdded) | Sort-Object)) {
        Write-AvmLog "sync: create $p" -Level Verbose | Out-Null
    }
    foreach ($p in $toUpdate) {
        Write-AvmLog ("sync: update {0} ({1})" -f $p, $updateReasons[$p]) -Level Verbose | Out-Null
    }
    foreach ($p in $lineUpdated) {
        Write-AvmLog "sync: update $p (managed lines)" -Level Verbose | Out-Null
    }
    foreach ($p in $toRemove) {
        Write-AvmLog "sync: delete $p" -Level Verbose | Out-Null
    }

    $issues = @()
    if ($CheckDrift) {
        Write-AvmLog 'sync: check-drift mode; reporting planned changes without writing' -Level Verbose | Out-Null
        $issueList = New-Object System.Collections.Generic.List[object]
        foreach ($p in $toRemove) {
            $issueList.Add((New-AvmSyncIssue -File $p -Message 'deleted file present in the repository; it should be removed.'))
        }
        foreach ($p in $toAdd) {
            $issueList.Add((New-AvmSyncIssue -File $p -Message 'managed file missing from the repository; it should be added.'))
        }
        foreach ($p in $toUpdate) {
            $issueList.Add((New-AvmSyncIssue -File $p -Message 'managed file is out of date; it should be updated.'))
        }
        foreach ($plan in $changedLinePlans) {
            $detail = ('managed lines out of date; {0} to add, {1} to remove.' -f $plan.AddedLines.Count, $plan.RemovedLines.Count)
            $issueList.Add((New-AvmSyncIssue -File $plan.Path -Message $detail))
        }
        $issues = $issueList.ToArray()
        $status = if ($issueList.Count -gt 0) { 'fail' } else { 'pass' }
    }
    else {
        $hasChanges = ($toAdd.Count + $toUpdate.Count + $toRemove.Count + $changedLinePlans.Count) -gt 0
        if ($hasChanges) {
            $applyDesc = ('sync managed files (add {0}, update {1}, remove {2}, merge-lines {3})' -f $toAdd.Count, $toUpdate.Count, $toRemove.Count, $changedLinePlans.Count)
            if ($PSCmdlet.ShouldProcess($root, $applyDesc)) {
                Write-AvmLog ("sync: applying managed-file plan to {0}" -f $root) -Level Verbose | Out-Null
                foreach ($p in $toRemove) {
                    $full = Join-Path $root ($p.Replace('/', [System.IO.Path]::DirectorySeparatorChar))
                    if (Test-Path -LiteralPath $full) {
                        Remove-Item -LiteralPath $full -Recurse -Force
                    }
                    Write-AvmLog 'sync: managed-file plan applied' -Level Verbose | Out-Null
                }
                foreach ($p in @($toAdd + $toUpdate)) {
                    $full = Join-Path $root ($p.Replace('/', [System.IO.Path]::DirectorySeparatorChar))
                    $parent = Split-Path -Parent $full
                    if ($parent -and -not (Test-Path -LiteralPath $parent)) {
                        New-Item -ItemType Directory -Path $parent -Force | Out-Null
                    }
                    [System.IO.File]::WriteAllBytes($full, $desired[$p].Bytes)

                    if ($desired[$p].Mode -eq '100755') {
                        Set-AvmManagedFileExecutableBit -Path $full
                    }
                }
                $lineEncoding = [System.Text.UTF8Encoding]::new($false)
                foreach ($plan in $changedLinePlans) {
                    $parent = Split-Path -Parent $plan.Full
                    if ($parent -and -not (Test-Path -LiteralPath $parent)) {
                        New-Item -ItemType Directory -Path $parent -Force | Out-Null
                    }
                    [System.IO.File]::WriteAllText($plan.Full, $plan.NewText, $lineEncoding)
                }
            }
        }
        $status = 'pass'
    }

    return [pscustomobject][ordered]@{
        Engine         = 'terraform'
        Tool           = 'managed-files'
        ToolPath       = $source.ToolPath
        ToolSource     = $source.SourceKind
        Status         = $status
        FilesProcessed = $desired.Count + $linePlans.Count
        Issues         = $issues
        Added          = @($toAdd + $lineAdded)
        Updated        = @($toUpdate + $lineUpdated)
        Removed        = $toRemove
    }
}

function Resolve-AvmManagedFilesSetting {
    <#
    .SYNOPSIS
        Resolve the effective managed-files settings by layering explicit
        parameters over environment variables, a repo-committed config file,
        and built-in defaults.
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Factory function; returns a settings hashtable and mutates no external state.')]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [string] $Root,

        [string] $ManagedFilesRepo,
        [string] $ManagedFilesRef,
        [string] $ManagedFilesPath,
        [string] $ManagedFilesLocalPath,
        [string] $FileGroupConfigPath,
        [string] $FileGroupConfigLocalPath,
        [string] $ConfigRepo,
        [string] $ConfigRef,
        [string] $ConfigPath,
        [string] $ConfigLocalPath,
        [string] $RepoId
    )

    $fileConfig = Get-AvmManagedFilesFileConfig -Root $Root

    $pick = {
        param([string]$Explicit, [string]$EnvName, [string]$FileKey, [string]$Default)
        if ($Explicit) { return $Explicit }
        $envValue = [System.Environment]::GetEnvironmentVariable($EnvName)
        if ($envValue) { return $envValue }
        if ($FileKey -and $fileConfig.ContainsKey($FileKey) -and $fileConfig[$FileKey]) { return [string]$fileConfig[$FileKey] }
        return $Default
    }

    # Managed file content lives in its own repository so that overlays can be
    # reviewed and released independently of the tooling. config.json stays in
    # the tools repository, so the config defaults are not derived from the
    # managed-files repo or ref.
    $repo = & $pick $ManagedFilesRepo 'AVM_MANAGED_FILES_REPO' 'repo' 'Azure/azure-verified-modules-managed-files'
    $ref = & $pick $ManagedFilesRef 'AVM_MANAGED_FILES_REF' 'ref' 'main'
    $path = & $pick $ManagedFilesPath 'AVM_MANAGED_FILES_PATH' 'path' 'terraform/files'
    $localPath = & $pick $ManagedFilesLocalPath 'AVM_MANAGED_FILES_LOCAL_PATH' 'localPath' ''

    $fileGroupConfigPath = & $pick $FileGroupConfigPath 'AVM_MANAGED_FILES_GROUP_CONFIG_PATH' 'fileGroupConfigPath' 'terraform/config/managed-files.json'
    $fileGroupConfigLocalPath = & $pick $FileGroupConfigLocalPath 'AVM_MANAGED_FILES_GROUP_CONFIG_LOCAL_PATH' 'fileGroupConfigLocalPath' ''

    $configRepoValue = & $pick $ConfigRepo 'AVM_MANAGED_FILES_CONFIG_REPO' 'configRepo' 'Azure/azure-verified-modules-tools'
    $configRefValue = & $pick $ConfigRef 'AVM_MANAGED_FILES_CONFIG_REF' 'configRef' 'main'
    $configPathValue = & $pick $ConfigPath 'AVM_MANAGED_FILES_CONFIG_PATH' 'configPath' 'repository-management/repository-config'
    $configLocalPath = & $pick $ConfigLocalPath 'AVM_MANAGED_FILES_CONFIG_LOCAL_PATH' 'configLocalPath' ''

    # RepoId is captured here only as its authoritative short-circuit value: an
    # explicit -RepoId parameter, the AVM_MANAGED_FILES_REPO_ID environment
    # variable, or a '.avm/managed-files.json' repoId override. Inference from the
    # git origin or the folder leaf happens later in
    # Resolve-AvmManagedFilesRepoId. Group membership prioritises a matching
    # candidate; the 'default' group's '*' wildcard covers every repository.
    $repoIdValue = & $pick $RepoId 'AVM_MANAGED_FILES_REPO_ID' 'repoId' ''

    return @{
        ManagedFilesRepo         = $repo
        ManagedFilesRef          = $ref
        ManagedFilesPath         = $path
        ManagedFilesLocalPath    = $localPath
        FileGroupConfigPath      = $fileGroupConfigPath
        FileGroupConfigLocalPath = $fileGroupConfigLocalPath
        ConfigRepo               = $configRepoValue
        ConfigRef                = $configRefValue
        ConfigPath               = $configPathValue
        ConfigLocalPath          = $configLocalPath
        RepoId                   = $repoIdValue
    }
}

function ConvertTo-AvmManagedFilesRepoId {
    <#
    .SYNOPSIS
        Normalise a repository name into a managed-files repository id by
        stripping a leading 'terraform-azurerm-' / 'terraform-azapi-' prefix.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowEmptyString()]
        [AllowNull()]
        [string] $Name
    )

    if ([string]::IsNullOrWhiteSpace($Name)) { return '' }

    $value = $Name.Trim()
    foreach ($prefix in @('terraform-azurerm-', 'terraform-azapi-')) {
        if ($value.StartsWith($prefix)) {
            $value = $value.Substring($prefix.Length)
            break
        }
    }

    return $value
}

function Get-AvmRepoLeafFromUrl {
    <#
    .SYNOPSIS
        Extract the repository leaf name from a git remote URL, handling HTTPS
        (with or without a trailing '.git'), SCP-style SSH
        (git@host:owner/repo.git) and ssh:// URLs.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowEmptyString()]
        [AllowNull()]
        [string] $Url
    )

    if ([string]::IsNullOrWhiteSpace($Url)) { return '' }

    $value = $Url.Trim().TrimEnd('/')
    $leaf = @($value -split '[:/]' | Where-Object { $_ }) | Select-Object -Last 1
    if ([string]::IsNullOrWhiteSpace($leaf)) { return '' }

    if ($leaf.EndsWith('.git')) {
        $leaf = $leaf.Substring(0, $leaf.Length - 4)
    }

    return $leaf
}

function Get-AvmManagedFilesOriginRepoId {
    <#
    .SYNOPSIS
        Resolve a normalised repository id from the 'origin' git remote of a
        working tree, or an empty string when there is no origin/git available.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string] $Root,

        [string] $GitPath
    )

    if ([string]::IsNullOrWhiteSpace($GitPath) -or [string]::IsNullOrWhiteSpace($Root)) {
        return ''
    }

    try {
        $result = Invoke-AvmProcess -FilePath $GitPath -ArgumentList @('-C', $Root, 'remote', 'get-url', 'origin') -IgnoreExitCode
    }
    catch {
        return ''
    }

    if (-not $result -or $result.ExitCode -ne 0) { return '' }

    $line = @($result.StdOut -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }) | Select-Object -First 1
    if ([string]::IsNullOrWhiteSpace($line)) { return '' }

    return ConvertTo-AvmManagedFilesRepoId -Name (Get-AvmRepoLeafFromUrl -Url $line)
}

function Get-AvmManagedFilesKnownRepoId {
    <#
    .SYNOPSIS
        Return the de-duplicated set of repository ids declared across every
        'repositoryGroups' entry of a parsed config.json.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [object] $RepositoryConfig
    )

    [string[]] $none = @()
    if (-not $RepositoryConfig) { return $none }
    if (-not ($RepositoryConfig.PSObject.Properties.Name -contains 'repositoryGroups')) { return $none }

    $ids = New-Object System.Collections.Generic.List[string]
    foreach ($group in @($RepositoryConfig.repositoryGroups)) {
        if (-not $group) { continue }
        if (-not ($group.PSObject.Properties.Name -contains 'repositories')) { continue }
        foreach ($repo in @($group.repositories)) {
            if (-not [string]::IsNullOrWhiteSpace($repo)) { $ids.Add([string]$repo) }
        }
    }

    [string[]] $unique = @($ids | Select-Object -Unique)
    return $unique
}

function Test-AvmManagedFilesInteractive {
    <#
    .SYNOPSIS
        Return whether the current host can prompt the user for a repository id.
        CI runs and redirected input are treated as non-interactive.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param()

    try {
        if (-not [string]::IsNullOrEmpty($env:CI)) { return $false }
        if ([System.Console]::IsInputRedirected) { return $false }
    }
    catch {
        return $false
    }

    return $true
}

function Resolve-AvmManagedFilesRepoId {
    <#
    .SYNOPSIS
        Resolve the managed-files repository id using the F11/F100 resolution
        order: explicit value, matching git-origin candidate, matching folder-leaf
        candidate, unmatched git-origin fallback, unmatched folder-leaf fallback,
        interactive prompt, then a hard failure.

    .DESCRIPTION
        An explicit -RepoId (already carrying the -RepoId parameter, the
        AVM_MANAGED_FILES_REPO_ID environment value or a '.avm/managed-files.json'
        override) short-circuits the whole chain and is authoritative.

        Otherwise a candidate is derived from the git origin remote and from the
        working-tree folder leaf, each normalised by stripping a leading
        'terraform-azurerm-' / 'terraform-azapi-' prefix. A candidate matching
        config.json repositoryGroups membership is preferred so an overlay is not
        lost when only one candidate matches. If neither matches, the origin and
        folder candidates remain valid for root-only sync. An interactive host is
        prompted only when neither automatic candidate exists; resolution fails
        only when no repository id can be determined.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string] $Root,

        [AllowEmptyString()]
        [string] $ExplicitRepoId = '',

        [string[]] $KnownRepoIds = @(),

        [string] $GitPath,

        [bool] $Interactive = $false
    )

    if (-not [string]::IsNullOrWhiteSpace($ExplicitRepoId)) {
        return $ExplicitRepoId.Trim()
    }

    $known = @($KnownRepoIds | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
    $originCandidate = Get-AvmManagedFilesOriginRepoId -Root $Root -GitPath $GitPath
    $rootLeaf = [System.IO.Path]::GetFileName([System.IO.Path]::TrimEndingDirectorySeparator($Root))
    $folderCandidate = ConvertTo-AvmManagedFilesRepoId -Name $rootLeaf

    if (-not [string]::IsNullOrWhiteSpace($originCandidate) -and ($known -contains $originCandidate)) {
        return $originCandidate
    }

    if (-not [string]::IsNullOrWhiteSpace($folderCandidate) -and ($known -contains $folderCandidate)) {
        return $folderCandidate
    }

    if (-not [string]::IsNullOrWhiteSpace($originCandidate)) {
        return $originCandidate
    }

    if (-not [string]::IsNullOrWhiteSpace($folderCandidate)) {
        return $folderCandidate
    }

    if ($Interactive) {
        $answer = Read-Host -Prompt 'Repository id could not be inferred. Enter the managed-files repository id'
        if (-not [string]::IsNullOrWhiteSpace($answer)) {
            $normalised = ConvertTo-AvmManagedFilesRepoId -Name $answer
            if (-not [string]::IsNullOrWhiteSpace($normalised)) { return $normalised }
        }
    }

    throw [AvmConfigurationException]::new(
        ("Could not resolve a managed-files repository id for '{0}' from its git origin or working-tree folder. " -f $Root) +
        "Set it explicitly with -RepoId, the AVM_MANAGED_FILES_REPO_ID environment variable, or a repoId in '.avm/managed-files.json'.")
}

function Set-AvmManagedFileExecutableBit {
    <#
    .SYNOPSIS
        Set the owner/group/other execute bits on a synced file's working-tree
        entry without ever touching the git index (F13). No-op on Windows.
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Working-tree file-mode repair; the caller already gates writes via ShouldProcess.')]
    param(
        [Parameter(Mandatory)]
        [string] $Path
    )

    if ($IsWindows) { return }
    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return }

    try {
        $item = Get-Item -LiteralPath $Path -Force
        $execute = [System.IO.UnixFileMode]::UserExecute -bor [System.IO.UnixFileMode]::GroupExecute -bor [System.IO.UnixFileMode]::OtherExecute
        $item.UnixFileMode = $item.UnixFileMode -bor $execute
    }
    catch {
        Write-AvmLog "Failed to set executable bit on '$Path': $($_.Exception.Message)" -Level Verbose
    }
}

function Get-AvmManagedFilesFileConfig {
    <#
    .SYNOPSIS
        Read the optional '.avm/managed-files.json' override file from a repo
        working tree, returning an empty hashtable when it is absent or invalid.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [string] $Root
    )

    $result = @{}
    $configPath = Join-Path (Join-Path $Root '.avm') 'managed-files.json'
    if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) { return $result }

    try {
        $json = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json
    }
    catch {
        Write-AvmLog "Failed to parse '$configPath': $($_.Exception.Message)" -Level Warning
        return $result
    }

    foreach ($property in $json.PSObject.Properties) {
        $result[$property.Name] = $property.Value
    }
    return $result
}

function Resolve-AvmManagedFilesSource {
    <#
    .SYNOPSIS
        Resolve the managed-files base directory (and optional config folder),
        fetching the source git repo into the AVM cache unless a local path
        override is supplied.
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Prepares a local checkout for read-only consumption; performs no destructive state change.')]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [hashtable] $Settings,

        [string] $GitPath
    )

    if ($Settings.ManagedFilesLocalPath) {
        if (-not (Test-Path -LiteralPath $Settings.ManagedFilesLocalPath -PathType Container)) {
            throw [System.IO.DirectoryNotFoundException]::new(
                "Managed-files local path not found: $($Settings.ManagedFilesLocalPath)")
        }
        $baseDir = (Resolve-Path -LiteralPath $Settings.ManagedFilesLocalPath).Path

        $configDir = $null
        if ($Settings.ConfigLocalPath -and (Test-Path -LiteralPath $Settings.ConfigLocalPath -PathType Container)) {
            $configDir = (Resolve-Path -LiteralPath $Settings.ConfigLocalPath).Path
        }

        return @{
            ManagedBaseDir      = $baseDir
            ConfigDir           = $configDir
            FileGroupConfigFile = (Resolve-AvmFileGroupConfigFile -Settings $Settings -ManagedBaseDir $baseDir)
            SourceKind          = 'local'
            ToolPath            = $baseDir
        }
    }

    if (-not $GitPath) {
        throw [System.InvalidOperationException]::new(
            "git is required to fetch managed files from '$($Settings.ManagedFilesRepo)' but was not found on PATH. Provide -ManagedFilesLocalPath to use a local source instead.")
    }

    $checkout = Get-AvmManagedFilesCheckout -Repo $Settings.ManagedFilesRepo -Ref $Settings.ManagedFilesRef -GitPath $GitPath
    $baseDir = Join-Path $checkout $Settings.ManagedFilesPath

    $fileGroupConfigFile = $Settings.FileGroupConfigLocalPath
    if (-not $fileGroupConfigFile) {
        $fileGroupConfigFile = Join-Path $checkout $Settings.FileGroupConfigPath
    }

    $configDir = $null
    if ($Settings.ConfigLocalPath -and (Test-Path -LiteralPath $Settings.ConfigLocalPath -PathType Container)) {
        $configDir = (Resolve-Path -LiteralPath $Settings.ConfigLocalPath).Path
    }
    elseif ($Settings.ConfigRepo -eq $Settings.ManagedFilesRepo -and $Settings.ConfigRef -eq $Settings.ManagedFilesRef) {
        $configDir = Join-Path $checkout $Settings.ConfigPath
    }
    else {
        $configCheckout = Get-AvmManagedFilesCheckout -Repo $Settings.ConfigRepo -Ref $Settings.ConfigRef -GitPath $GitPath
        $configDir = Join-Path $configCheckout $Settings.ConfigPath
    }

    return @{
        ManagedBaseDir      = $baseDir
        ConfigDir           = $configDir
        FileGroupConfigFile = $fileGroupConfigFile
        SourceKind          = 'governance'
        ToolPath            = $baseDir
    }
}

function Resolve-AvmFileGroupConfigFile {
    <#
    .SYNOPSIS
        Locate the file-group config for a local managed-files path.

    .DESCRIPTION
        Uses an explicit FileGroupConfigLocalPath when supplied. Otherwise
        assumes the managed-files repository layout, where the files directory
        and the config directory are siblings, and looks for
        '<parent-of-files>/config/managed-files.json'.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [hashtable] $Settings,

        [Parameter(Mandatory)]
        [string] $ManagedBaseDir
    )

    if ($Settings.FileGroupConfigLocalPath) { return $Settings.FileGroupConfigLocalPath }

    $parent = Split-Path -Parent $ManagedBaseDir
    if (-not $parent) { return $null }
    return (Join-Path (Join-Path $parent 'config') 'managed-files.json')
}

function Get-AvmManagedFilesCheckout {
    <#
    .SYNOPSIS
        Shallow clone (or fetch, when already cached) a git repo at a ref into
        the AVM cache and return the checkout root.
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Populates a private cache directory used read-only by the caller; not a user-facing state change.')]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string] $Repo,

        [Parameter(Mandatory)]
        [string] $Ref,

        [Parameter(Mandatory)]
        [string] $GitPath
    )

    $homeDir = if ($env:AVM_HOME) { $env:AVM_HOME } else { Join-Path ([System.IO.Path]::GetTempPath()) 'avm' }
    $slug = $Repo -replace '[\\/]', '_'
    $cacheRoot = Join-Path (Join-Path (Join-Path (Join-Path $homeDir 'cache') 'managed-files') $slug) $Ref

    if (Test-Path -LiteralPath (Join-Path $cacheRoot '.git') -PathType Container) {
        Invoke-AvmProcess -FilePath $GitPath -ArgumentList @('-C', $cacheRoot, 'fetch', '--depth', '1', 'origin', $Ref) | Out-Null
        Invoke-AvmProcess -FilePath $GitPath -ArgumentList @('-C', $cacheRoot, 'checkout', '-q', 'FETCH_HEAD') | Out-Null
    }
    else {
        $parent = Split-Path -Parent $cacheRoot
        if ($parent -and -not (Test-Path -LiteralPath $parent)) {
            New-Item -ItemType Directory -Path $parent -Force | Out-Null
        }
        if (Test-Path -LiteralPath $cacheRoot) {
            Remove-Item -LiteralPath $cacheRoot -Recurse -Force
        }
        Invoke-AvmProcess -FilePath $GitPath -ArgumentList @('clone', '--depth', '1', '--branch', $Ref, "https://github.com/$Repo.git", $cacheRoot) | Out-Null
    }

    return $cacheRoot
}

function Get-AvmGitIndexMode {
    <#
    .SYNOPSIS
        Read git tree-entry modes ('100644' / '100755') from a directory's git
        index, keyed by forward-slash relative path. Returns an empty map when
        git is unavailable or the directory is not a working tree.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [string] $Dir,

        [string] $GitPath
    )

    $modeMap = @{}
    if (-not $GitPath) { return $modeMap }
    if (-not (Test-Path -LiteralPath $Dir -PathType Container)) { return $modeMap }

    $result = $null
    try {
        $result = Invoke-AvmProcess -FilePath $GitPath -ArgumentList @('-C', $Dir, 'ls-files', '--stage', '--', '.') -IgnoreExitCode
    }
    catch {
        return $modeMap
    }

    if ($result.ExitCode -ne 0 -or [string]::IsNullOrEmpty($result.StdOut)) { return $modeMap }

    foreach ($line in ($result.StdOut -split "`n")) {
        if ($line -match '^(\d{6})\s+[0-9a-f]+\s+\d+\t(.+)$') {
            $modeMap[($matches[2] -replace '\\', '/')] = $matches[1]
        }
    }
    return $modeMap
}

function Resolve-AvmManagedFileTargetPath {
    <#
    .SYNOPSIS
        Resolve a managed source path to its concrete repository target paths.

    .DESCRIPTION
        Paths below a reserved '<parent>/_all/' source subtree are broadcast
        into each existing immediate child directory of the corresponding
        target parent. Multiple reserved segments are expanded from left to
        right. A root-level '_all/' and all other paths are returned unchanged.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)]
        [string] $RelativePath,

        [Parameter(Mandatory)]
        [string] $TargetRoot
    )

    $broadcast = [System.Text.RegularExpressions.Regex]::Match(
        $RelativePath,
        '^(.+?)/_all/(.+)$',
        [System.Text.RegularExpressions.RegexOptions]::CultureInvariant)
    if (-not $broadcast.Success) {
        return $RelativePath
    }

    $parentPath = $broadcast.Groups[1].Value
    $suffix = $broadcast.Groups[2].Value
    $parentRoot = Join-Path $TargetRoot ($parentPath.Replace('/', [System.IO.Path]::DirectorySeparatorChar))
    if (-not (Test-Path -LiteralPath $parentRoot -PathType Container)) {
        return
    }

    Get-ChildItem -LiteralPath $parentRoot -Directory -Force |
        Where-Object { $_.Name -ne '_all' } |
        Sort-Object -Property Name |
        ForEach-Object {
            $expandedPath = "$parentPath/$($_.Name)/$suffix"
            Resolve-AvmManagedFileTargetPath -RelativePath $expandedPath -TargetRoot $TargetRoot
        }
}

function Add-AvmManagedFilesFromDir {
    <#
    .SYNOPSIS
        Add every file under a base directory to a managed-files map keyed by
        forward-slash relative path, capturing the source path and git index
        mode. Dotfiles are included; '.gitkeep' placeholders are not.
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Populates a caller-owned hashtable; performs no external state change.')]
    param(
        [string] $BaseDir,

        [Parameter(Mandatory)]
        [hashtable] $Map,

        [Parameter(Mandatory)]
        [string] $TargetRoot,

        [string] $GitPath
    )

    if ([string]::IsNullOrEmpty($BaseDir)) { return }
    if (-not (Test-Path -LiteralPath $BaseDir -PathType Container)) {
        Write-AvmLog "Managed files directory does not exist: $BaseDir" -Level Warning
        return
    }

    # Get-Item (not Resolve-Path) so the prefix matches Get-ChildItem FullName:
    # Resolve-Path preserves 8.3 short components, Get-ChildItem expands them.
    $baseDirAbsolute = (Get-Item -LiteralPath $BaseDir -Force).FullName
    $modeMap = Get-AvmGitIndexMode -Dir $baseDirAbsolute -GitPath $GitPath

    $lineSpecName = Get-AvmManagedLineSpecFileName

    # -Force: dotfiles are hidden on Linux/macOS and would be skipped silently.
    $sourceFiles = @(
        Get-ChildItem -LiteralPath $baseDirAbsolute -Recurse -File -Force | Where-Object {
            # '.gitkeep' files exist only to keep otherwise-empty overlay
            # directories tracked in git. They are placeholders, never real managed
            # content, so they must not be synced into target repos. Repository
            # sync applies the same filter; omitting it here would make every
            # drift check demand a file the sync never writes.
            # The line-managed-file spec is tooling metadata consumed separately, so
            # it is filtered here for the same reason.
            $_.Name -ne '.gitkeep' -and $_.Name -ne $lineSpecName
        } | ForEach-Object {
            $relativePath = [System.IO.Path]::GetRelativePath($baseDirAbsolute, $_.FullName) -replace '\\', '/'
            $mode = $modeMap[$relativePath]
            if (-not $mode) { $mode = '100644' }
            [pscustomobject]@{
                RelativePath = $relativePath
                Source       = $_.FullName -replace '\\', '/'
                Mode         = $mode
                IsBroadcast  = $relativePath -cmatch '^.+?/_all/.+'
            }
        }
    )

    # Broadcast templates are applied before literal paths from the same source,
    # so a concrete path remains the more-specific override. Build calls this
    # helper once per source in precedence order, preserving overlay wins.
    $broadcastFiles = @($sourceFiles | Where-Object { $_.IsBroadcast } | Sort-Object -Property RelativePath)
    $literalFiles = @($sourceFiles | Where-Object { -not $_.IsBroadcast } | Sort-Object -Property RelativePath)
    foreach ($sourceFile in @($broadcastFiles + $literalFiles)) {
        foreach ($targetPath in @(Resolve-AvmManagedFileTargetPath -RelativePath $sourceFile.RelativePath -TargetRoot $TargetRoot)) {
            $Map[$targetPath] = @{
                Source = $sourceFile.Source
                Mode   = $sourceFile.Mode
            }
        }
    }
}

function Build-AvmManagedFilesMap {
    <#
    .SYNOPSIS
        Build the managed-files map and the deleted-file list by walking the
        ordered file groups that apply to a repository.

    .DESCRIPTION
        File groups are applied in the order supplied. A file present in more
        than one group is taken from the last group that declares it, so later
        groups win over earlier ones.

        Each group may also declare deleted files. A deletion removes the path
        from the map at the point the group is applied, so a later group can
        re-add a file that an earlier group deleted, and a later group can
        delete a file that an earlier group added. Any path still present in
        the map at the end is not reported as deleted.

        Returns a hashtable with 'Files' (target path -> source descriptor) and
        'Deleted' (sorted target paths that should not exist in the repository).
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Factory function; returns a new hashtable and mutates no external state.')]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [string] $BaseDir,

        [Parameter(Mandatory)]
        [string] $TargetRoot,

        [string[]] $FileGroups = @(),

        [hashtable] $DeletedFilesByGroup = @{},

        [string] $RepoId,

        [string] $GitPath
    )

    $map = @{}
    $deleted = @{}

    foreach ($fileGroup in $FileGroups) {
        if ([string]::IsNullOrWhiteSpace($fileGroup)) { continue }

        Add-AvmManagedFilesFromDir -BaseDir (Join-Path $BaseDir $fileGroup) -Map $map -TargetRoot $TargetRoot -GitPath $GitPath

        if (-not $DeletedFilesByGroup.ContainsKey($fileGroup)) { continue }
        foreach ($deletedPath in @($DeletedFilesByGroup[$fileGroup])) {
            if ([string]::IsNullOrWhiteSpace($deletedPath)) { continue }
            foreach ($resolvedPath in @(Resolve-AvmManagedFileTargetPath -RelativePath $deletedPath -TargetRoot $TargetRoot)) {
                if ($map.ContainsKey($resolvedPath)) {
                    $map.Remove($resolvedPath) | Out-Null
                    Write-AvmLog "File group '$fileGroup' deletes managed file: $resolvedPath" -Level Verbose
                }
                $deleted[$resolvedPath] = $true
            }
        }
    }

    # A later group re-adding a path un-deletes it.
    foreach ($presentPath in @($map.Keys)) { $deleted.Remove($presentPath) | Out-Null }

    Write-AvmLog "Resolved $($map.Count) managed file(s) and $($deleted.Count) deleted file(s) for repository '$RepoId' (fileGroups='$($FileGroups -join ', ')')." -Level Verbose

    return @{
        Files   = $map
        Deleted = @($deleted.Keys | Sort-Object)
    }
}

function Get-AvmManagedFilesDeletedFileMap {
    <#
    .SYNOPSIS
        Read the managed-files repository's file-group config and return a map
        of file group name to the paths that group deletes.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [string] $Path
    )

    $map = @{}
    $config = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
    if (-not ($config.PSObject.Properties.Name -contains 'fileGroups') -or -not $config.fileGroups) { return $map }

    foreach ($fileGroup in @($config.fileGroups)) {
        if (-not $fileGroup.name) { continue }
        if ($fileGroup.PSObject.Properties.Name -contains 'deletedFiles' -and $fileGroup.deletedFiles) {
            $map[[string]$fileGroup.name] = @($fileGroup.deletedFiles)
        }
    }

    return $map
}

function Resolve-AvmManagedFilesRepositorySetting {
    <#
    .SYNOPSIS
        Resolve the ordered managed-file groups that apply to a repository from a
        parsed config.json by matching the repository id against
        'repositoryGroups'.

    .DESCRIPTION
        A repository may belong to several groups that each declare a
        'managedFiles' list. All of them apply, stacked so that a later group's
        files win over an earlier one's.

        Stacking order is explicit: each group may carry an integer 'order'
        (default 0). Groups sort by that value ascending, with ties broken by
        declaration order in config.json. A lower order is applied earlier and
        therefore *loses* to a higher order. Relying on declaration order alone
        was fragile - reordering config.json for tidiness silently changed
        precedence.

        The 'default' group matches every repository via the '*' wildcard and
        carries a negative order so that its files always apply first.
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Factory function; returns a settings hashtable and mutates no external state.')]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [object] $RepositoryConfig,

        [Parameter(Mandatory)]
        [string] $RepoId
    )

    $repositoryGroups = @()
    if ($RepositoryConfig.PSObject.Properties.Name -contains 'repositoryGroups' -and $RepositoryConfig.repositoryGroups) {
        $repositoryGroups = @(
            $RepositoryConfig.repositoryGroups |
                Where-Object { $_.repositories -contains '*' -or $_.repositories -contains $RepoId }
        )
    }

    $groupEntries = @()
    $declarationIndex = 0
    foreach ($repositoryGroup in $repositoryGroups) {
        if ($repositoryGroup.PSObject.Properties.Name -contains 'managedFiles' -and $repositoryGroup.managedFiles) {
            $order = 0
            if ($repositoryGroup.PSObject.Properties.Name -contains 'order' -and $null -ne $repositoryGroup.order) {
                $order = [int] $repositoryGroup.order
            }
            foreach ($fileGroup in @($repositoryGroup.managedFiles)) {
                $groupEntries += [pscustomobject]@{
                    FileGroup = $fileGroup
                    Order     = $order
                    Index     = $declarationIndex
                }
            }
        }
        $declarationIndex++
    }

    $fileGroups = @(
        $groupEntries |
            Sort-Object -Property Order, Index |
            Select-Object -ExpandProperty FileGroup |
            Select-Object -Unique
    )

    return @{
        FileGroups = $fileGroups
    }
}

function Get-AvmMatchingDeprecatedPath {
    <#
    .SYNOPSIS
        Return the subset of deprecated candidate paths that are present on
        disk under a repository root, matching either an exact file or a
        directory.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [string[]] $CandidatePaths = @(),

        [Parameter(Mandatory)]
        [string] $Root
    )

    $matched = @()
    foreach ($candidate in $CandidatePaths) {
        if ([string]::IsNullOrEmpty($candidate)) { continue }
        $full = Join-Path $Root ($candidate.Replace('/', [System.IO.Path]::DirectorySeparatorChar))
        if (Test-Path -LiteralPath $full) {
            $matched += $candidate
        }
    }
    return $matched
}

function Get-AvmGitBlobSha {
    <#
    .SYNOPSIS
        Compute git's blob SHA-1 for the given content bytes in-process:
        sha1("blob " + length + "\0" + content).
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [byte[]] $Bytes
    )

    if ($null -eq $Bytes) { $Bytes = New-Object byte[] 0 }

    $header = [System.Text.Encoding]::ASCII.GetBytes("blob $($Bytes.Length)`0")
    $combined = New-Object byte[] ($header.Length + $Bytes.Length)
    [System.Array]::Copy($header, 0, $combined, 0, $header.Length)
    [System.Array]::Copy($Bytes, 0, $combined, $header.Length, $Bytes.Length)

    $sha1 = [System.Security.Cryptography.SHA1]::Create()
    try {
        $hashBytes = $sha1.ComputeHash($combined)
        return ([System.BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant()
    }
    finally {
        $sha1.Dispose()
    }
}

function Get-AvmDesiredManagedFile {
    <#
    .SYNOPSIS
        Build the desired managed-file set as { path -> @{ Bytes; Sha; Mode } }
        by reading each source file's bytes and computing its git blob SHA.
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Factory function; returns a new hashtable and mutates no external state.')]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [hashtable] $ManagedFiles
    )

    $desired = @{}
    foreach ($targetPath in $ManagedFiles.Keys) {
        $entry = $ManagedFiles[$targetPath]
        $sourcePath = $entry.Source
        $mode = $entry.Mode
        if (-not $mode) { $mode = '100644' }
        if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) {
            Write-AvmLog "Managed file source missing on disk: $sourcePath (target=$targetPath)" -Level Warning
            continue
        }
        $bytes = [System.IO.File]::ReadAllBytes($sourcePath)
        $desired[$targetPath] = @{
            Bytes = $bytes
            Sha   = Get-AvmGitBlobSha -Bytes $bytes
            Mode  = $mode
        }
    }
    return $desired
}

function New-AvmSyncIssue {
    <#
    .SYNOPSIS
        Build a shared-shape Issue object for the managed-files sync engine.
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Factory function; returns a new pscustomobject and mutates no external state.')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [string] $File,

        [Parameter(Mandatory)]
        [string] $Message
    )

    [pscustomobject][ordered]@{
        File     = $File
        Line     = 0
        Column   = 0
        Severity = 'error'
        Code     = ''
        Message  = $Message
    }
}

# SIG # Begin signature block
# MIInKwYJKoZIhvcNAQcCoIInHDCCJxgCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAatF8tBeNpXpg3
# jOD9u16U0aMXe2n5PYVogvkb5zSki6CCDLowggX1MIID3aADAgECAhMzAAACHU0Z
# yE7XD1dIAAAAAAIdMA0GCSqGSIb3DQEBCwUAMFcxCzAJBgNVBAYTAlVTMR4wHAYD
# VQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBD
# b2RlIFNpZ25pbmcgUENBIDIwMjQwHhcNMjYwNDE2MTg1OTQzWhcNMjcwNDE1MTg1
# OTQzWjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UE
# BxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYD
# VQQDExVNaWNyb3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IB
# DwAwggEKAoIBAQDQvewXxx9gZZFC6Ys1WBay8BJ8kGA4JQnH5CMafqOASlTpK9H8
# o5ZXTXt0caVQTNMUPt445wXYD+dFtaKWTwDn1I52oUSrC9vJin1Gsqt+zyKJL5Dg
# 3eQXbQNR61DmMy20GLTIO3SFed9Rfi/ophgCLGFLDR3r0KvHjwMb/jYWS0celV/4
# Lz27LfAekm8v9E5IXaeiXbAUYZKK090n4CVl3JBtbN+9DtI9SNu/yjvozW52/u7R
# X/Ttpa/KDlpuokZ+Zcbvmtd9ur9gFLvZzh41o9MsE/clQtdaFWGvuo6Jua/ntpgk
# ey3E5/vBFe+MJPG6phdnuo6r57ZudCudiI1bAgMBAAGjggGbMIIBlzAOBgNVHQ8B
# Af8EBAMCB4AwHwYDVR0lBBgwFgYKKwYBBAGCN0wIAQYIKwYBBQUHAwMwHQYDVR0O
# BBYEFH6QuMwqcPG0hQlQ6c5jCtTTLrVeMEUGA1UdEQQ+MDykOjA4MR4wHAYDVQQL
# ExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xFjAUBgNVBAUTDTIzMDAxMis1MDc1NTkw
# HwYDVR0jBBgwFoAUf1k/VCHarU/vBeXmo9ctBpQSCDEwYAYDVR0fBFkwVzBVoFOg
# UYZPaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9jcmwvTWljcm9zb2Z0
# JTIwQ29kZSUyMFNpZ25pbmclMjBQQ0ElMjAyMDI0LmNybDBtBggrBgEFBQcBAQRh
# MF8wXQYIKwYBBQUHMAKGUWh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMv
# Y2VydHMvTWljcm9zb2Z0JTIwQ29kZSUyMFNpZ25pbmclMjBQQ0ElMjAyMDI0LmNy
# dDAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4ICAQBKTbYOjzwTG/DXGaz9
# s6+fQeaTtDcFmMY+5UyVFCyj7Pv+5i37qfX8lSL/tBIfYQfWsMuBQlfZurJD6r4H
# VJ2CeH+1fgiq8dcHdVKoZ3Sa2qXoX3cq9iS8cVb06B7+5/XJ7I0OxHH9fDsvJ3T3
# w5V/ZtAIFmLrl+P0CtG+92uzRsn0nTbdFjOkLMLWPLAU3THohKRlSEMgFJpPkm5n
# 5UAZ35xX6FWCrDLsSKb555bTifwa8mJBwdlof0bmfYidH+dxZ1FdDxvLnNl9zeKs
# A4kejaaIqqIPguhwAti5Ql7BlTNoJNwxCvBmqW2MQLnCkYN/VVUsR3V2x/rcTNzo
# Bf/Z/SpROvdaA2ZOOd1uioXJt3tdLQ7vHpqpib0KfWr/FWXW10q38VxfCnRQBqzb
# SuztR7nEMuzX7Ck+B/XaPDXd1qh72+QYyB0Z2VzWmO9zsnb9Uq/dwu8LGeQqnyu6
# 7SDGACvnXii2fb9+US492VTnXSnFKyqwgzUyFMtZK1/sHYTv6bG4TtQUygQxTN+Z
# V+aJIlKO2MqZ7bKrAnOzS9m6NgoTdWOq11bTOZwKlIEV/EhV9SWkDmdpR/hPPT2v
# 6TEj4F8PT/zHjRezIU5c/DGlt/VhY/pK0XkJtEyMmmS1BMtjU/rqBZVMIm3dnxQs
# /TBByr+Cf8Z1r7aifQVQ+WSqzjCCBr0wggSloAMCAQICEzMAAAA5O7Y3Gb8GHWcA
# AAAAADkwDQYJKoZIhvcNAQEMBQAwgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpX
# YXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQg
# Q29ycG9yYXRpb24xMjAwBgNVBAMTKU1pY3Jvc29mdCBSb290IENlcnRpZmljYXRl
# IEF1dGhvcml0eSAyMDExMB4XDTI0MDgwODIwNTQxOFoXDTM2MDMyMjIyMTMwNFow
# VzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEo
# MCYGA1UEAxMfTWljcm9zb2Z0IENvZGUgU2lnbmluZyBQQ0EgMjAyNDCCAiIwDQYJ
# KoZIhvcNAQEBBQADggIPADCCAgoCggIBANgBnB7jOMeqlRYHNa265v4IY9fH8TKh
# emHfPINe1gpLaV3dhg324WwH06LcHbpnsBukCDNitryo0dtS/EW6I/yEL/bLSY8h
# KpbfQuWusBPr9qazYcDxCW/qnjb5JsI1s8bNOg3bVATvQVL4tcf03aTycsz8QeCd
# M0l/yHRObJ9QqazM1r6VPEOJ7LL+uEEb73w6QCuhs89a1uv1zerOYMnsneRRwCbp
# yW11IcggU0cRKDDq1pjVJzIbIF6+oiXXbReOsgeI8zu1FyQfK0fVkaya8SmVHQ/t
# Of23mZ4W9k0Ri22QW9p3UgSC5OUDktKxxcCmGL6tXLfOGSWHIIV4YrTJTT6PNty5
# REojHJuZHArkF9VnHTERWoTjAzfI3kP+5b4alUdhgAZ7ttOu1bVnXfHaqPYl2rPs
# 20ji03LOVWsh/radgE17es5hL+t6lV0eVHrVhsssROWJuz2MXMCt7iw7lFPG9LXK
# Gjsmonn2gotGdHIuEg5JnJMJVmixd5LRlkmgYRZKzhxSCwyoGIq0PhaA7Y+VPct5
# pCHkijcIIDm0nlkK+0KyepolcqGm0T/GYQRMhHJlGOOmVQop36wUVUYklUy++vDW
# eEgEo4s7hxN6mIbf2MSIQ/iIfMZgJxC69oukMUXCrOC3SkE/xIkgpfl22MM1itkZ
# 35nNXkMolU1lAgMBAAGjggFOMIIBSjAOBgNVHQ8BAf8EBAMCAYYwEAYJKwYBBAGC
# NxUBBAMCAQAwHQYDVR0OBBYEFH9ZP1Qh2q1P7wXl5qPXLQaUEggxMBkGCSsGAQQB
# gjcUAgQMHgoAUwB1AGIAQwBBMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU
# ci06AjGQQ7kUBU7h6qfHMdEjiTQwWgYDVR0fBFMwUTBPoE2gS4ZJaHR0cDovL2Ny
# bC5taWNyb3NvZnQuY29tL3BraS9jcmwvcHJvZHVjdHMvTWljUm9vQ2VyQXV0MjAx
# MV8yMDExXzAzXzIyLmNybDBeBggrBgEFBQcBAQRSMFAwTgYIKwYBBQUHMAKGQmh0
# dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2kvY2VydHMvTWljUm9vQ2VyQXV0MjAx
# MV8yMDExXzAzXzIyLmNydDANBgkqhkiG9w0BAQwFAAOCAgEAFJQfOChP7onn6fLI
# MKrSlN1WYKwDFgAddymOUO3FrM8d7B/W/iQ6DxXsDn7D5W4wMwYeLystcEqfkjz4
# NURRgazyMu5yRzQh4LqjA4tStTcJh1opExo7nn5PuPBYnbu0+THSuVHTe0VTTPVh
# ily/piFrDo3axQ9P4C+Ol5yet+2gTfekICS5xS+cYfSIvgn0JksVBVMYVI5QFu/q
# hnLhsEFEUzG8fvv0hjgkO+lkpV9ty6GkN4vdnd7ya6Q6aR9y34aiM1qmxaxBi6OU
# nyNl6fkuun/diTFnYDLTppOkr/mg5WSfCiDVMNCxtj4wPKC5OmHm1DQIt/MNokbb
# H3UGsFP1QbzsLocuSqLCvH09Io3fDPTmscR9Y75G4qX7RTX8AdBPo0I6OEojf39z
# uFZt0qOHm65YWQE69cZM2ueE1MB05dNNgHK9gTE7zKvK/fg8B2qjW88MT/WF5V5u
# vZGtqa9FSL2RazArA+rDPuf6JGYz4HpgMZHB4S6szWSKYBv0VisCzfxgeU+dquXW
# 9bd0auYlOB58DPcOYKdc3Se94g+xL4pcEhbB54JOgAkwYTu/9dLeH2pDqeJZAABV
# DWRQCaXfO5LgyKwKCLYXpigrZYCjUSBcr+Ve8PFWMhVTQl0v4q8J/AUmQN5W4n10
# 1cY2L4A7GTQG1h32HHAvfQESWP0xghnHMIIZwwIBATBuMFcxCzAJBgNVBAYTAlVT
# MR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jv
# c29mdCBDb2RlIFNpZ25pbmcgUENBIDIwMjQCEzMAAAIdTRnITtcPV0gAAAAAAh0w
# DQYJYIZIAWUDBAIBBQCggZAwGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwLwYJ
# KoZIhvcNAQkEMSIEINupgly1d+DufBG3xqDmyGSsjUsfF0UUXQJELz3Y7fp0MEIG
# CisGAQQBgjcCAQwxNDAyoBSAEgBNAGkAYwByAG8AcwBvAGYAdKEagBhodHRwOi8v
# d3d3Lm1pY3Jvc29mdC5jb20wDQYJKoZIhvcNAQEBBQAEggEACj9XWE1jE3Np4KuU
# mOorggKLwbgNqwKvv7Mh987C+OYm96wq2gTJ3VRJb0hyoRvmVT6/g92KOsytSODF
# 3PaagWpCewlmYZAsoFDIONPP1fBY+QfMivH2oEDlSFkTEHrl5kPw/qnuxx/FNtIK
# DA8iPBFUoi4Zhcz+Bw1KICH3zwTB8ufGhyGPcdZuOy59iFvv0k1c52fj8jPDFJNa
# QaNnXxFKQH/t4reROAZrT0RSom8kbr4ifq3Xnnl88Cg1GD8TFbP4FGMMEcx49PjP
# VDhT7iKgaSVonQvvXTeNj6KroQr7e7w6l3CZDK3FUQLwPdxZzAmFm8PddW0kkSMM
# P2RPQKGCF5cwgheTBgorBgEEAYI3AwMBMYIXgzCCF38GCSqGSIb3DQEHAqCCF3Aw
# ghdsAgEDMQ8wDQYJYIZIAWUDBAIBBQAwggFSBgsqhkiG9w0BCRABBKCCAUEEggE9
# MIIBOQIBAQYKKwYBBAGEWQoDATAxMA0GCWCGSAFlAwQCAQUABCDM+R8Ms74Qskse
# jm212P/G0fzryHY+a6gx8LeD29atEQIGal/72C8kGBMyMDI2MDgxMjA4MzYwMy4y
# MDZaMASAAgH0oIHRpIHOMIHLMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGlu
# Z3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBv
# cmF0aW9uMSUwIwYDVQQLExxNaWNyb3NvZnQgQW1lcmljYSBPcGVyYXRpb25zMScw
# JQYDVQQLEx5uU2hpZWxkIFRTUyBFU046N0YwMC0wNUUwLUQ5NDcxJTAjBgNVBAMT
# HE1pY3Jvc29mdCBUaW1lLVN0YW1wIFNlcnZpY2WgghHtMIIHIDCCBQigAwIBAgIT
# MwAAAh6jrKRuOW98SQABAAACHjANBgkqhkiG9w0BAQsFADB8MQswCQYDVQQGEwJV
# UzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UE
# ChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGlt
# ZS1TdGFtcCBQQ0EgMjAxMDAeFw0yNjAyMTkxOTM5NDlaFw0yNzA1MTcxOTM5NDla
# MIHLMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMH
# UmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSUwIwYDVQQL
# ExxNaWNyb3NvZnQgQW1lcmljYSBPcGVyYXRpb25zMScwJQYDVQQLEx5uU2hpZWxk
# IFRTUyBFU046N0YwMC0wNUUwLUQ5NDcxJTAjBgNVBAMTHE1pY3Jvc29mdCBUaW1l
# LVN0YW1wIFNlcnZpY2UwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCl
# 0TjtbDwsR7Fe8ac6ol5s1zhtTqd2AWpchQhLp9G5mmSM23N5fyQGCQ1D06rOA3Pg
# XKF+76vXvOCs2VsLv1owj4mHEyEqiq8GJ5yC+/QNYRpZPA8e7OgekzDO6S/4vy/j
# TMYbp3rhuFiKKCzTWOQtdFcF+D0k369I7pm/E07SyNMGkuNd5lj5SJ91UqFuZfjM
# B6cQ2wh77mtiRUVdj53yjdNqj+GQl+Yaz29Bjrzn7U1ln+JpLlnb0xdGmZoIPKZb
# wBVcWtyL4uyhML7SSTmiOfWXU+g+yNl0CdoLGL8LtWHEi8FsuTPeSdSqmeMrvLaE
# mibTVTS4vQQY8NPnb6uI5y6iNV9vBFcm8LU/lDTjGTqPa7UBT4gdf5Jm3wYrfCFZ
# 4P/j5MoqT0JONca50jt4TGI90SihXaDEYqk23S0IJZ3UkUpukDRTjK713BIykffx
# yBqMeQqfO0zvWfUx7BrmUpugQcw99+DxLl2gf+uQEpRmnlbrVJ9dvW9ds4fqEPN2
# jG0QwF1PBSglNcV1SpqZKitQgBGSwu/82AKztoCHwYRHRNwzwTVe/1KNTvmqAd4U
# ges4ywOH02haagT8wYY8OdWdjKn3k052w+kmc0UC0F+iVXTGZIMxvo9iBZQoXehz
# RtWJ/VOtKvCyS3csKzN7rStWJwjSWz6dtOf0l+ytLQIDAQABo4IBSTCCAUUwHQYD
# VR0OBBYEFOYKFprqBB0JZmJcFC4cPPmeF4JkMB8GA1UdIwQYMBaAFJ+nFV0AXmJd
# g/Tl0mWnG1M1GelyMF8GA1UdHwRYMFYwVKBSoFCGTmh0dHA6Ly93d3cubWljcm9z
# b2Z0LmNvbS9wa2lvcHMvY3JsL01pY3Jvc29mdCUyMFRpbWUtU3RhbXAlMjBQQ0El
# MjAyMDEwKDEpLmNybDBsBggrBgEFBQcBAQRgMF4wXAYIKwYBBQUHMAKGUGh0dHA6
# Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY2VydHMvTWljcm9zb2Z0JTIwVGlt
# ZS1TdGFtcCUyMFBDQSUyMDIwMTAoMSkuY3J0MAwGA1UdEwEB/wQCMAAwFgYDVR0l
# AQH/BAwwCgYIKwYBBQUHAwgwDgYDVR0PAQH/BAQDAgeAMA0GCSqGSIb3DQEBCwUA
# A4ICAQCkoZB5NnJVFb5wKejRonk518a2TBNYpKcBMtfL6BS0ARaABOMGYLlPNuhI
# 1HwmelP9hX3oq3TaEm/cDkkzNQAzDedPgoRI2R7+8poNSWvHXEAs7SZODm9x7Kql
# BkNZM9ex4XY1yNmVOAmWDjRr7jKjaiQbntf7EC4GNikxGGaVWOjfYt3Q9X0r/Ks8
# KBlbzDR9zjA/TCctR4co1WpU1ZRLFrB9bl8dRxsbnyT2qQ41E7dT12R30eIGUziE
# s5GN+26V/ovXOi20dJiM13hYWvy1NNJAhkKOlLB1ONund6ffhPdUcHWsu8V+lR0a
# akMV64HqDbLumZrCNwUofVx3xMk8F4tCYJtQxLTywc30sZAD1S2sC1959x6KixA+
# p41FLUl8g64oHy3bfYnH5xd4JOBgQoaqndGjcctxr+8EknjhKyrgAzrTcKLJbUez
# goye8brCLJ+y6PAoEjpXRkSYAU8wfQ3YWRck6ALwoV7Uin8+rpGQSbXhF6c1dTFa
# kXmChClud4IADY/t6JRkJ+06FzL+jDd8KLV8Qj77JfiuTiPIG5G/xlnGoZFcX+yy
# BtDvzZE48d+Y+HYUd/cvhH1FKl7AH+5AyotqJSFmvM/BuYRx2B20asVXilV2k2Jb
# NO3LGCz3Q+dpElzwsfJrka1N/getma7fWpowsNvoIaEQvjad8TCCB3EwggVZoAMC
# AQICEzMAAAAVxedrngKbSZkAAAAAABUwDQYJKoZIhvcNAQELBQAwgYgxCzAJBgNV
# BAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4w
# HAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xMjAwBgNVBAMTKU1pY3Jvc29m
# dCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAyMDEwMB4XDTIxMDkzMDE4MjIy
# NVoXDTMwMDkzMDE4MzIyNVowfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hp
# bmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jw
# b3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTAw
# ggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDk4aZM57RyIQt5osvXJHm9
# DtWC0/3unAcH0qlsTnXIyjVX9gF/bErg4r25PhdgM/9cT8dm95VTcVrifkpa/rg2
# Z4VGIwy1jRPPdzLAEBjoYH1qUoNEt6aORmsHFPPFdvWGUNzBRMhxXFExN6AKOG6N
# 7dcP2CZTfDlhAnrEqv1yaa8dq6z2Nr41JmTamDu6GnszrYBbfowQHJ1S/rboYiXc
# ag/PXfT+jlPP1uyFVk3v3byNpOORj7I5LFGc6XBpDco2LXCOMcg1KL3jtIckw+DJ
# j361VI/c+gVVmG1oO5pGve2krnopN6zL64NF50ZuyjLVwIYwXE8s4mKyzbnijYjk
# lqwBSru+cakXW2dg3viSkR4dPf0gz3N9QZpGdc3EXzTdEonW/aUgfX782Z5F37Zy
# L9t9X4C626p+Nuw2TPYrbqgSUei/BQOj0XOmTTd0lBw0gg/wEPK3Rxjtp+iZfD9M
# 269ewvPV2HM9Q07BMzlMjgK8QmguEOqEUUbi0b1qGFphAXPKZ6Je1yh2AuIzGHLX
# pyDwwvoSCtdjbwzJNmSLW6CmgyFdXzB0kZSU2LlQ+QuJYfM2BjUYhEfb3BvR/bLU
# HMVr9lxSUV0S2yW6r1AFemzFER1y7435UsSFF5PAPBXbGjfHCBUYP3irRbb1Hode
# 2o+eFnJpxq57t7c+auIurQIDAQABo4IB3TCCAdkwEgYJKwYBBAGCNxUBBAUCAwEA
# ATAjBgkrBgEEAYI3FQIEFgQUKqdS/mTEmr6CkTxGNSnPEP8vBO4wHQYDVR0OBBYE
# FJ+nFV0AXmJdg/Tl0mWnG1M1GelyMFwGA1UdIARVMFMwUQYMKwYBBAGCN0yDfQEB
# MEEwPwYIKwYBBQUHAgEWM2h0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMv
# RG9jcy9SZXBvc2l0b3J5Lmh0bTATBgNVHSUEDDAKBggrBgEFBQcDCDAZBgkrBgEE
# AYI3FAIEDB4KAFMAdQBiAEMAQTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB
# /zAfBgNVHSMEGDAWgBTV9lbLj+iiXGJo0T2UkFvXzpoYxDBWBgNVHR8ETzBNMEug
# SaBHhkVodHRwOi8vY3JsLm1pY3Jvc29mdC5jb20vcGtpL2NybC9wcm9kdWN0cy9N
# aWNSb29DZXJBdXRfMjAxMC0wNi0yMy5jcmwwWgYIKwYBBQUHAQEETjBMMEoGCCsG
# AQUFBzAChj5odHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpL2NlcnRzL01pY1Jv
# b0NlckF1dF8yMDEwLTA2LTIzLmNydDANBgkqhkiG9w0BAQsFAAOCAgEAnVV9/Cqt
# 4SwfZwExJFvhnnJL/Klv6lwUtj5OR2R4sQaTlz0xM7U518JxNj/aZGx80HU5bbsP
# MeTCj/ts0aGUGCLu6WZnOlNN3Zi6th542DYunKmCVgADsAW+iehp4LoJ7nvfam++
# Kctu2D9IdQHZGN5tggz1bSNU5HhTdSRXud2f8449xvNo32X2pFaq95W2KFUn0CS9
# QKC/GbYSEhFdPSfgQJY4rPf5KYnDvBewVIVCs/wMnosZiefwC2qBwoEZQhlSdYo2
# wh3DYXMuLGt7bj8sCXgU6ZGyqVvfSaN0DLzskYDSPeZKPmY7T7uG+jIa2Zb0j/aR
# AfbOxnT99kxybxCrdTDFNLB62FD+CljdQDzHVG2dY3RILLFORy3BFARxv2T5JL5z
# bcqOCb2zAVdJVGTZc9d/HltEAY5aGZFrDZ+kKNxnGSgkujhLmm77IVRrakURR6nx
# t67I6IleT53S0Ex2tVdUCbFpAUR+fKFhbHP+CrvsQWY9af3LwUFJfn6Tvsv4O+S3
# Fb+0zj6lMVGEvL8CwYKiexcdFYmNcP7ntdAoGokLjzbaukz5m/8K6TT4JDVnK+AN
# uOaMmdbhIurwJ0I9JZTmdHRbatGePu1+oDEzfbzL6Xu/OHBE0ZDxyKs6ijoIYn/Z
# cGNTTY3ugm2lBRDBcQZqELQdVTNYs6FwZvKhggNQMIICOAIBATCB+aGB0aSBzjCB
# yzELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1Jl
# ZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjElMCMGA1UECxMc
# TWljcm9zb2Z0IEFtZXJpY2EgT3BlcmF0aW9uczEnMCUGA1UECxMeblNoaWVsZCBU
# U1MgRVNOOjdGMDAtMDVFMC1EOTQ3MSUwIwYDVQQDExxNaWNyb3NvZnQgVGltZS1T
# dGFtcCBTZXJ2aWNloiMKAQEwBwYFKw4DAhoDFQCD/QNkKDIW4VIF7j3oi2qbrR0a
# /6CBgzCBgKR+MHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAw
# DgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24x
# JjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwMA0GCSqGSIb3
# DQEBCwUAAgUA7iYmxzAiGA8yMDI2MDgxMTIyNTUwM1oYDzIwMjYwODEyMjI1NTAz
# WjB3MD0GCisGAQQBhFkKBAExLzAtMAoCBQDuJibHAgEAMAoCAQACAjH2AgH/MAcC
# AQACAhI0MAoCBQDuJ3hHAgEAMDYGCisGAQQBhFkKBAIxKDAmMAwGCisGAQQBhFkK
# AwKgCjAIAgEAAgMHoSChCjAIAgEAAgMBhqAwDQYJKoZIhvcNAQELBQADggEBABiP
# o6+PakRzoBBqWiY/yC18naBK2JbOnGiITTXxt2oI3zAmLRoHBGblUaMuMo0973RL
# NxTUZtNfWa1NfpiPf+Tl6/if0GT7mqrQ9wipjYEsIp4Vz/CTmtKmVusWXR2eN2W8
# 3s5pJMXOzTDldX+4sKbsMI4BoD3yusFDTdOqP2/FYk1u1gGF/mksFovim3RRMe/1
# q2gffrhgJmZiN9Jby36YS2C69lnt1XEYZaq+R/GPMk6ex2eq4iaexWfTzA+kEl80
# wfZzKOB0kk9oybnZgoyik9QabTEf6UAI56lX36TunIMZstG+TNdhdetFRShJK9As
# YBhTIJsAcz2CfT3+/XgxggQNMIIECQIBATCBkzB8MQswCQYDVQQGEwJVUzETMBEG
# A1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWlj
# cm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGltZS1TdGFt
# cCBQQ0EgMjAxMAITMwAAAh6jrKRuOW98SQABAAACHjANBglghkgBZQMEAgEFAKCC
# AUowGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMC8GCSqGSIb3DQEJBDEiBCDO
# ADa47x+YXsH6cg+EP8axZAkXN6Rk3G7P6Z4IIRNCaDCB+gYLKoZIhvcNAQkQAi8x
# geowgecwgeQwgb0EIC+BXWrz9geMgM8Bvn8bqxHjhHXJ29EBizITIw0B9vOCMIGY
# MIGApH4wfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNV
# BAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQG
# A1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTACEzMAAAIeo6ykbjlv
# fEkAAQAAAh4wIgQgpAWF3FeyzKvieM7G6rWGCJDxkYXEYhfMJ0nuoqOPAV4wDQYJ
# KoZIhvcNAQELBQAEggIAVj0natDNTL60ACyamaxamse0L6LRtAZHOa2SFF9uMnNi
# kgjxmEZ4mevXz60Q9g+AbKyYLox7zyn8RRT4F49I5smp4l0D6CdbFGUz8bZ87TzI
# tFvVU+gWG/aFVivVw5vTlbIKwdyMVI6rv5+YIqfa1rkeGddpkKZlFICHf06IvgDQ
# K6uFJMcbHrKzCVwabHScFAQz7Q6cBxi9CwgQFfGTE3D/+1r8rhfOSuDQZu5NgJYI
# ALOEUZ+drz0TTFb14brK01UEC9lSm07zJxJXm3wCIWcwY/TGUcv33V/mYvoz7ybc
# FSgxMid+bHRtlM40KsvW/n8oSVEH02I2N99Rz+1w0QuEyjhF10AnAGLqr3CjhlnD
# aNkym/6l2VY2egkLkvIxXRGL45gkYRVrUZLNlBnhHgwey1vTYEstWOUXqbzqMg54
# Ulzhx7tKtj0S64xXz7us4A33SjUyKkyjOn9ali7N06GGaGMXbpg3Y34sN87o8ReM
# hb0PWCS1S7NxbaDGoeBLt2VSJKH+SYqK7XcmsxRtevf++i1f9dJTp3sZl1yPfj8r
# 35s6PJTxSSlIYoZ40YD6qGtoVKroyHcG+CNfMsNSy2yHBNi/cT3U5FwYR6+I3E5r
# EE/zhY7ObbudVtYYF6oIzXPy2hXpnR9CsBM+Bzc4f9Ez1lhAedE7q+iNbcZPeGI=
# SIG # End signature block