src/Services/RepoDiscoveryService.ps1

# RepoDiscoveryService — Escanea un directorio buscando repos git.
# Para v3 minimum: scan inmediato (depth 1) del path raíz.

class RepoDiscoveryService {
    # Nota: $Git tipado como [object] y no [GitService]. PS resuelve los tipos en
    # propiedades/parámetros de class al parse-time del archivo, y otros archivos
    # con clases NO se ven en ese contexto (gotcha/ps-class-scope). Late binding
    # vía duck typing.
    [object] $Git

    RepoDiscoveryService($git) {
        $this.Git = $git
    }

    # Lista los hijos directos de $rootPath. Cada hijo se evalúa vía GitService.
    # Devuelve Repo[] (incluye nogit folders).
    [Repo[]] Discover([string]$rootPath) {
        if (-not (Test-Path $rootPath)) { return @() }

        $children = Get-ChildItem -Path $rootPath -Directory -Force -ErrorAction SilentlyContinue
        if (-not $children) { return @() }

        $repos = [System.Collections.Generic.List[object]]::new()
        foreach ($dir in $children) {
            # Skip dirs ocultas/sistemas
            if ($dir.Name.StartsWith('.') -or $dir.Name.StartsWith('$')) { continue }
            $repo = $this.Git.GetRepoState($dir.FullName)
            if ($repo.Status -eq 'nogit' -and $this.Git.HasGitChildren($dir.FullName)) {
                $repo.IsContainer = $true
            }
            $repos.Add($repo)
        }
        return $repos.ToArray()
    }

    # Variante shallow: enumera los hijos pero NO ejecuta git status sobre ellos.
    # Devuelve Repo[] con Status='unloaded'. El caller decide cuándo cargar
    # cada uno (selectivo en modo Favorites, on-demand en None). Mucho más
    # rápido en repos grandes con red lenta.
    [Repo[]] DiscoverShallow([string]$rootPath) {
        if (-not (Test-Path $rootPath)) { return @() }

        $children = Get-ChildItem -Path $rootPath -Directory -Force -ErrorAction SilentlyContinue
        if (-not $children) { return @() }

        $repos = [System.Collections.Generic.List[object]]::new()
        foreach ($dir in $children) {
            if ($dir.Name.StartsWith('.') -or $dir.Name.StartsWith('$')) { continue }
            $repo = $this.Git.BuildShallowRepo($dir.FullName)
            $repos.Add($repo)
        }
        return $repos.ToArray()
    }

    # Variante paralela: enumera + ejecuta git status en threads paralelos.
    # 8× más rápido que Discover() secuencial en máquinas lentas con muchos
    # repos. Para N < 5 repos el spinup overhead no compensa — fallback a
    # secuencial.
    #
    # Limitación PS 7: dentro de ForEach-Parallel no se ven las classes
    # definidas en el script padre. Por eso usamos Util/GitParse.ps1 con la
    # función standalone Get-RepoStateData que devuelve hashtables, y
    # mapeamos a [Repo] en el parent runspace. Pasamos el body de la función
    # como string por $using: y la reconstruimos con [scriptblock]::Create
    # adentro de cada thread (PS 7 no permite pasar scriptblocks via $using:).
    [Repo[]] DiscoverParallel([string]$rootPath, [int]$throttleLimit) {
        if (-not (Test-Path $rootPath)) { return @() }

        $children = Get-ChildItem -Path $rootPath -Directory -Force -ErrorAction SilentlyContinue
        if (-not $children) { return @() }

        # Filtrar dirs sistema/oculto. Se hace una vez en el parent.
        $candidates = @($children | Where-Object {
            -not $_.Name.StartsWith('.') -and -not $_.Name.StartsWith('$')
        })

        # Threshold: spinup de runspaces tiene costo. Por debajo de 5 repos
        # el secuencial gana. Lo medimos en bench, ajustable acá.
        if ($candidates.Count -lt 5) {
            return $this.Discover($rootPath)
        }

        $paths = @($candidates | ForEach-Object { $_.FullName })

        # Cuerpo de la función standalone como string. Get-Command devuelve el
        # script body sin la firma `function NAME` — perfecto para Create.
        $fnBody = (Get-Command Get-RepoStateData -ErrorAction Stop).Definition

        # Run paralelo. ThrottleLimit acota cuántos git.exe corren al mismo
        # tiempo. 8 es buen default: 4 cores físicos × 2 hyperthreads sat.
        # Cada hashtable se devuelve al parent.
        $hashtables = $paths | ForEach-Object -Parallel {
            $body = $using:fnBody
            $sb = [scriptblock]::Create($body)
            & $sb -Path $_
        } -ThrottleLimit $throttleLimit

        # Mapear hashtables → [Repo]. Acá sí podemos usar la clase, estamos
        # en el parent runspace.
        $repos = [System.Collections.Generic.List[object]]::new()
        foreach ($h in $hashtables) {
            if ($null -eq $h) { continue }
            $repo = [Repo]::new()
            $repo.Path = $h.Path
            $repo.Name = $h.Name
            $repo.Id = $h.Id
            $repo.Status = $h.Status
            $repo.Branch = $h.Branch
            $repo.IsOnMainBranch = $h.IsOnMainBranch
            $repo.Ahead = $h.Ahead
            $repo.Behind = $h.Behind
            $repo.Modified = $h.Modified
            $repo.Added = $h.Added
            $repo.Deleted = $h.Deleted
            $repo.Untracked = $h.Untracked
            $repo.StashCount = $h.StashCount
            if ($null -ne $h.LastCommit) {
                $repo.LastCommit = [Commit]::new(
                    $h.LastCommit.Hash,
                    $h.LastCommit.Message,
                    $h.LastCommit.Author,
                    $h.LastCommit.Date
                )
            }
            # Container detection — si nogit y tiene git children, marcar.
            if ($repo.Status -eq 'nogit' -and $this.Git.HasGitChildren($repo.Path)) {
                $repo.IsContainer = $true
            }
            $repos.Add($repo)
        }
        return $repos.ToArray()
    }

    [Repo[]] DiscoverParallel([string]$rootPath) {
        return $this.DiscoverParallel($rootPath, 8)
    }

}