src/Services/GitService.ps1

# GitService — Wrapper de comandos git para obtener estado de un repo.
# Sin cache persistente (ADR-004) — sólo cache en memoria con TTL en GetRepoState.

class GitService {
    [hashtable] $StateCache = @{}   # path → @{ state=Repo; fetchedAt=DateTime }
    [int]       $CacheTtlSeconds = 30

    # Callback opcional invocado después de cada Pull/Push/Fetch exitoso. Recibe
    # el $path del repo. Permite que un coordinator externo (Startup) actualice
    # Settings.LastFetchByRepo sin que GitService conozca SettingsService.
    # Mantiene a GitService como wrapper puro de git, side-effects opt-in.
    [scriptblock] $OnFetchSuccess = $null

    GitService() { }
    GitService([int]$ttl) { $this.CacheTtlSeconds = $ttl }

    hidden [void] NotifyFetched([string]$path) {
        if ($null -ne $this.OnFetchSuccess) {
            try { & $this.OnFetchSuccess $path } catch { $null = $_ }
        }
    }

    # ¿Es repo git? Existencia de .git como dir o como file (worktree).
    # Implementado como instance method para evitar self-static calls que a veces
    # rompen el parse-time lookup del propio tipo dentro de su clase.
    [bool] IsGitRepo([string]$path) {
        return (Test-Path -LiteralPath (Join-Path $path '.git'))
    }

    # ¿La carpeta tiene al menos UN repo git al primer nivel adentro? Considera
    # tanto `.git/` (repo normal) como `.git` archivo (worktree linkeado).
    # Skip dirs ocultos / sistemas. Lo usan tanto el discovery como el lazy-load
    # para marcar containers (carpeta sin .git con subrepos git adentro).
    [bool] HasGitChildren([string]$path) {
        $kids = Get-ChildItem -Path $path -Directory -Force -ErrorAction SilentlyContinue
        if (-not $kids) { return $false }
        foreach ($kid in $kids) {
            if ($kid.Name.StartsWith('.') -or $kid.Name.StartsWith('$')) { continue }
            if (Test-Path -LiteralPath (Join-Path $kid.FullName '.git')) { return $true }
        }
        return $false
    }

    # Edad en segundos del entry de cache para $path. -1 si no hay entry.
    [int] CacheAgeSeconds([string]$path) {
        $entry = $this.StateCache[$path]
        if (-not $entry) { return -1 }
        return [int]((Get-Date) - $entry.fetchedAt).TotalSeconds
    }

    # Estado completo de un repo. Devuelve un Repo con Branch, Status, ahead/behind y conteos.
    # Repo "shallow" — solo Path/Name/Id, Status='unloaded'. NO ejecuta ningún
    # comando git. Se usa cuando AutoLoadMode != 'All' para que la lista
    # arranque al instante y el user vaya cargando solo lo que necesita.
    # GetRepoState(path) sobre el mismo path después rellena el resto.
    #
    # Excepción: si la carpeta NO tiene .git pero TIENE repos git al primer
    # nivel adentro, la marcamos directo como container — sino al boot se ve
    # como 'unloaded' y luego de pasar el cursor se queda como 'no git' (el
    # check de container estaba solo en Discover/DiscoverParallel, no en el
    # flujo lazy). Es barato (Get-ChildItem + Test-Path, sin git invocations).
    [Repo] BuildShallowRepo([string]$path) {
        $repo = [Repo]::new()
        $repo.Path = $path
        $repo.Name = Split-Path $path -Leaf
        $repo.Id = $repo.Name.ToLower() -replace '[^a-z0-9]', '-'
        $repo.Branch = '—'

        if (-not (Test-Path -LiteralPath (Join-Path $path '.git')) -and $this.HasGitChildren($path)) {
            $repo.Status = 'nogit'
            $repo.IsContainer = $true
            return $repo
        }

        $repo.Status = 'unloaded'
        return $repo
    }

    [Repo] GetRepoState([string]$path) {
        $age = $this.CacheAgeSeconds($path)
        if ($age -ge 0 -and $age -lt $this.CacheTtlSeconds) {
            return $this.StateCache[$path].state
        }

        $repo = [Repo]::new()
        $repo.Path = $path
        $repo.Name = Split-Path $path -Leaf
        $repo.Id = $repo.Name.ToLower() -replace '[^a-z0-9]', '-'

        if (-not $this.IsGitRepo($path)) {
            $repo.Status = 'nogit'
            $repo.Branch = '—'
            if ($this.HasGitChildren($path)) {
                $repo.IsContainer = $true
            }
            return $this.UpdateCache($path, $repo)
        }

        # Porcelain v2 con --branch: emite headers '# branch.*' + entries 1/2/u/?
        $output = $this.RunGit($path, @('status', '--porcelain=v2', '--branch'))
        if ($null -eq $output) {
            $repo.Status = 'nogit'  # error tratamos como nogit
            return $this.UpdateCache($path, $repo)
        }

        $hasConflict = $false
        foreach ($line in $output) {
            if ($line.StartsWith('# branch.head ')) {
                $repo.Branch = $line.Substring(14).Trim()
                $repo.IsOnMainBranch = ($repo.Branch -in @('main', 'master', 'develop'))
            }
            elseif ($line.StartsWith('# branch.ab ')) {
                # formato: # branch.ab +<ahead> -<behind>
                $parts = $line.Substring(12).Trim() -split '\s+'
                if ($parts.Count -eq 2) {
                    $repo.Ahead  = [int]($parts[0].TrimStart('+'))
                    $repo.Behind = [int]($parts[1].TrimStart('-'))
                }
            }
            elseif ($line.StartsWith('1 ') -or $line.StartsWith('2 ')) {
                # entry tracked: "1 XY ... path" o "2 XY ... path1<TAB>path2" (rename)
                $xy = $line.Substring(2, 2)
                $x = $xy[0]; $y = $xy[1]

                if ($x -ne '.') {
                    # algo staged
                    switch ($x) {
                        'M' { $repo.Modified++ }
                        'A' { $repo.Added++ }
                        'D' { $repo.Deleted++ }
                        'R' { $repo.Modified++ }
                        'C' { $repo.Added++ }
                        default { $repo.Modified++ }
                    }
                }
                if ($y -ne '.') {
                    switch ($y) {
                        'M' { $repo.Modified++ }
                        'A' { $repo.Added++ }
                        'D' { $repo.Deleted++ }
                        'R' { $repo.Modified++ }
                        default { $repo.Modified++ }
                    }
                }
            }
            elseif ($line.StartsWith('? ')) {
                $repo.Untracked++
            }
            elseif ($line.StartsWith('u ')) {
                $hasConflict = $true
            }
        }

        # Stashes
        $stashOutput = $this.RunGit($path, @('stash', 'list'))
        if ($stashOutput) {
            $repo.StashCount = @($stashOutput).Count
        }

        # Last commit (corto)
        $lastLine = $this.RunGit($path, @('log', '-1', '--format=%h%x09%an%x09%cr%x09%s'))
        if ($lastLine -and $lastLine.Count -gt 0) {
            $parts = $lastLine[0] -split "`t", 4
            if ($parts.Count -eq 4) {
                $repo.LastCommit = [Commit]::new($parts[0], $parts[3], $parts[1], $parts[2])
            }
        }

        # Status derivado. Prioridad: conflict > dirty > unpushed > behind > clean.
        # 'behind' es estado nuevo: working tree limpio, sin commits para pushear,
        # pero el remoto tiene cosas que te faltan.
        $dirty = $repo.DirtyCount()
        if ($hasConflict)              { $repo.Status = 'conflict' }
        elseif ($dirty -gt 0)          { $repo.Status = 'dirty' }
        elseif ($repo.Ahead -gt 0)     { $repo.Status = 'unpushed' }
        elseif ($repo.Behind -gt 0)    { $repo.Status = 'behind' }
        else                            { $repo.Status = 'clean' }

        return $this.UpdateCache($path, $repo)
    }

    [Branch[]] GetBranches([string]$path) {
        if (-not $this.IsGitRepo($path)) { return @() }

        # current branch primero
        $current = ($this.RunGit($path, @('rev-parse', '--abbrev-ref', 'HEAD')))
        $currentName = if ($current) { $current[0].Trim() } else { '' }

        # for-each-ref con upstream tracking
        $fmt = '%(refname:short)%09%(upstream:short)%09%(upstream:track)%09%(committerdate:relative)%09%(authorname)'
        $lines = $this.RunGit($path, @('for-each-ref', '--format', $fmt, 'refs/heads/'))
        if (-not $lines) { return @() }

        $branches = @()
        foreach ($line in $lines) {
            $parts = $line -split "`t", 5
            if ($parts.Count -lt 1) { continue }

            $b = [Branch]::new()
            $b.Name = $parts[0]
            $b.IsCurrent = ($parts[0] -eq $currentName)
            if ($parts.Count -ge 2 -and $parts[1]) {
                $b.Remote = ($parts[1] -split '/', 2)[0]
            }
            # upstream:track viene como "[ahead 3, behind 1]" o "[gone]" o vacio
            if ($parts.Count -ge 3 -and $parts[2]) {
                if ($parts[2] -match 'ahead (\d+)')  { $b.Ahead = [int]$Matches[1] }
                if ($parts[2] -match 'behind (\d+)') { $b.Behind = [int]$Matches[1] }
            }
            if ($parts.Count -ge 4) { $b.LastCommitDate   = $parts[3] }
            if ($parts.Count -ge 5) { $b.LastCommitAuthor = $parts[4] }
            $b.IsProtected = ($b.Name -in @('main', 'master', 'develop')) -or ($b.Name -match '^release/')
            $branches += $b
        }
        return $branches
    }

    # Cambia la branch del repo. Falla si hay cambios no committeados que conflictúan
    # con la branch destino (git lo detecta — no validamos acá, dejamos que git decida).
    # Devuelve hashtable @{ Ok=$bool; Message=$string } para que el caller pueda
    # mostrar feedback al usuario sin tener que parsear stderr de git.
    [hashtable] CheckoutBranch([string]$path, [string]$branchName) {
        if (-not $this.IsGitRepo($path)) {
            return @{ Ok = $false; Message = "no es un repo git: $path" }
        }
        if ([string]::IsNullOrWhiteSpace($branchName)) {
            return @{ Ok = $false; Message = "nombre de branch vacío" }
        }

        $stderr = $this.RunGitCapturingStderr($path, @('checkout', $branchName))
        if ($stderr.ExitCode -eq 0) {
            $this.InvalidatePath($path)
            return @{ Ok = $true; Message = "switched to $branchName" }
        }
        # Limpiamos el ruido típico de git: primera línea suele ser la causa.
        $msg = ($stderr.Stderr -split "`n" | Select-Object -First 1).Trim()
        if (-not $msg) { $msg = "checkout falló (exit $($stderr.ExitCode))" }
        return @{ Ok = $false; Message = $msg }
    }

    # Crea una nueva branch desde HEAD. Si checkout=true, también la activa.
    [hashtable] CreateBranch([string]$path, [string]$branchName, [bool]$checkout) {
        if (-not $this.IsGitRepo($path)) {
            return @{ Ok = $false; Message = "no es un repo git: $path" }
        }
        if ([string]::IsNullOrWhiteSpace($branchName)) {
            return @{ Ok = $false; Message = "nombre de branch vacío" }
        }

        $cmdArgs = if ($checkout) { @('checkout', '-b', $branchName) } else { @('branch', $branchName) }
        $r = $this.RunGitCapturingStderr($path, $cmdArgs)
        if ($r.ExitCode -eq 0) {
            $this.InvalidatePath($path)
            $verb = if ($checkout) { 'creada y switcheada' } else { 'creada' }
            return @{ Ok = $true; Message = "${verb}: $branchName" }
        }
        return @{ Ok = $false; Message = $this.FirstStderrLine($r, "create falló (exit $($r.ExitCode))") }
    }

    # Borra una branch local. force=true equivale a -D (ignora si no está merged).
    # git rechaza borrar la current branch — no duplicamos esa validación acá, el
    # mensaje de stderr de git es claro.
    [hashtable] DeleteLocalBranch([string]$path, [string]$branchName, [bool]$force) {
        if (-not $this.IsGitRepo($path)) {
            return @{ Ok = $false; Message = "no es un repo git: $path" }
        }
        if ([string]::IsNullOrWhiteSpace($branchName)) {
            return @{ Ok = $false; Message = "nombre de branch vacío" }
        }

        $flag = if ($force) { '-D' } else { '-d' }
        $r = $this.RunGitCapturingStderr($path, @('branch', $flag, $branchName))
        if ($r.ExitCode -eq 0) {
            $this.InvalidatePath($path)
            return @{ Ok = $true; Message = "borrada: $branchName" }
        }
        return @{ Ok = $false; Message = $this.FirstStderrLine($r, "delete falló (exit $($r.ExitCode))") }
    }

    # Borra la branch del remote. Default: 'origin'.
    [hashtable] DeleteRemoteBranch([string]$path, [string]$branchName, [string]$remote) {
        if (-not $this.IsGitRepo($path)) {
            return @{ Ok = $false; Message = "no es un repo git: $path" }
        }
        if ([string]::IsNullOrWhiteSpace($branchName)) {
            return @{ Ok = $false; Message = "nombre de branch vacío" }
        }
        if ([string]::IsNullOrWhiteSpace($remote)) { $remote = 'origin' }

        $r = $this.RunGitCapturingStderr($path, @('push', $remote, '--delete', $branchName))
        if ($r.ExitCode -eq 0) {
            $this.InvalidatePath($path)
            return @{ Ok = $true; Message = "borrada del remote: $branchName" }
        }
        return @{ Ok = $false; Message = $this.FirstStderrLine($r, "delete remote falló (exit $($r.ExitCode))") }
    }

    # Pull en la branch actual con --ff-only (sin merge automático — si falla, el
    # user decide si quiere rebase manual). Para flujos más complejos, agregar
    # variante futura.
    [hashtable] Pull([string]$path) {
        if (-not $this.IsGitRepo($path)) {
            return @{ Ok = $false; Message = "no es un repo git: $path" }
        }
        $r = $this.RunGitCapturingStderr($path, @('pull', '--ff-only'))
        if ($r.ExitCode -eq 0) {
            $this.InvalidatePath($path)
            $this.NotifyFetched($path)
            return @{ Ok = $true; Message = "pull OK" }
        }
        return @{ Ok = $false; Message = $this.FirstStderrLine($r, "pull falló (exit $($r.ExitCode))") }
    }

    # Push de la current branch. noVerify omite los hooks pre-push (--no-verify).
    # Si la branch no tiene upstream, agregamos --set-upstream origin <branch>
    # automático — patrón común en TUIs.
    [hashtable] Push([string]$path, [bool]$noVerify) {
        if (-not $this.IsGitRepo($path)) {
            return @{ Ok = $false; Message = "no es un repo git: $path" }
        }

        # SIEMPRE agregamos --set-upstream origin <currentBranch>. Si ya hay
        # upstream, set-upstream lo reconfigura sin daño. Si no hay (branch nueva,
        # tracking config inválida), lo crea. Detectar "tiene upstream" con
        # rev-parse @{u} es engañoso porque Git puede tener config local que
        # apunta a una branch remota inexistente y reportarlo como "tiene".
        $cmdArgs = @('push', '--set-upstream', 'origin')
        $currentArr = $this.RunGit($path, @('rev-parse', '--abbrev-ref', 'HEAD'))
        if ($currentArr -and $currentArr[0]) {
            $cmdArgs += $currentArr[0].Trim()
        }
        if ($noVerify) { $cmdArgs += '--no-verify' }

        $r = $this.RunGitCapturingStderr($path, $cmdArgs)
        if ($r.ExitCode -eq 0) {
            $this.InvalidatePath($path)
            $this.NotifyFetched($path)
            $msg = if ($noVerify) { 'push OK (sin hooks)' } else { 'push OK' }
            return @{ Ok = $true; Message = $msg }
        }
        return @{ Ok = $false; Message = $this.FirstStderrLine($r, "push falló (exit $($r.ExitCode))") }
    }

    # Lista archivos cambiados entre dos branches/refs. Cada item:
    # @{ Status; File }. Status: 'A'/'M'/'D'/'R'/'C'.
    [object[]] DiffNameStatus([string]$path, [string]$branchA, [string]$branchB) {
        if (-not $this.IsGitRepo($path)) { return @() }
        if ([string]::IsNullOrWhiteSpace($branchA) -or [string]::IsNullOrWhiteSpace($branchB)) { return @() }

        $lines = $this.RunGit($path, @('diff', '--name-status', "$branchA..$branchB"))
        if (-not $lines) { return @() }

        $result = @()
        foreach ($line in $lines) {
            $parts = $line -split "`t", 2
            if ($parts.Count -lt 2) { continue }
            # Renames vienen como 'R100\tsource\tdest' — parts[0] empieza con R
            $status = $parts[0].Substring(0, 1)
            $file = $parts[1]
            $result += @{ Status = $status; File = $file }
        }
        return $result
    }

    # Devuelve el unified diff de un file entre dos branches/refs como array de
    # líneas. Incluye headers (@@, ---, +++, diff --git, index) — el caller
    # decide cómo coloreaerlas. Vacío si no hay diff o el file no existe.
    [string[]] DiffFile([string]$path, [string]$branchA, [string]$branchB, [string]$file) {
        if (-not $this.IsGitRepo($path)) { return @() }
        if ([string]::IsNullOrWhiteSpace($file)) { return @() }

        $lines = $this.RunGit($path, @('diff', "$branchA..$branchB", '--', $file))
        if (-not $lines) { return @() }
        return @($lines)
    }

    # Devuelve commits de $sourceBranch que NO están en $excludeBranch. Útil para
    # cherry-pick: mostrar al user los commits "exclusivos" de la otra branch.
    # Cada item es hashtable @{ Hash; Subject; Author; Date }.
    [object[]] GetBranchLog([string]$path, [string]$sourceBranch, [string]$excludeBranch, [int]$max) {
        if (-not $this.IsGitRepo($path)) { return @() }
        if ([string]::IsNullOrWhiteSpace($sourceBranch)) { return @() }
        if ($max -le 0) { $max = 50 }

        $cmdArgs = @('log', $sourceBranch)
        if (-not [string]::IsNullOrWhiteSpace($excludeBranch)) {
            $cmdArgs += '--not'
            $cmdArgs += $excludeBranch
        }
        $cmdArgs += '--format=%h%x09%s%x09%an%x09%cr'
        $cmdArgs += "-$max"

        $lines = $this.RunGit($path, $cmdArgs)
        if (-not $lines) { return @() }

        $result = @()
        foreach ($line in $lines) {
            $parts = $line -split "`t", 4
            if ($parts.Count -lt 2) { continue }
            $result += @{
                Hash    = $parts[0]
                Subject = $parts[1]
                Author  = if ($parts.Count -ge 3) { $parts[2] } else { '' }
                Date    = if ($parts.Count -ge 4) { $parts[3] } else { '' }
            }
        }
        return $result
    }

    # Cherry-pick de un commit por hash. Si hay conflict, --abort para dejar el
    # working tree limpio (el caller decide si advertir o reintentar manual).
    [hashtable] CherryPick([string]$path, [string]$commitHash) {
        if (-not $this.IsGitRepo($path)) {
            return @{ Ok = $false; Message = "no es un repo git: $path" }
        }
        if ([string]::IsNullOrWhiteSpace($commitHash)) {
            return @{ Ok = $false; Message = "hash vacío" }
        }
        $r = $this.RunGitCapturingStderr($path, @('cherry-pick', $commitHash))
        if ($r.ExitCode -eq 0) {
            $this.InvalidatePath($path)
            return @{ Ok = $true; Message = "cherry-pick OK: $commitHash" }
        }
        # Conflict u otro error: abort para no dejar el repo en estado intermedio.
        $abortResult = $this.RunGitCapturingStderr($path, @('cherry-pick', '--abort'))
        $msg = $this.FirstStderrLine($r, "cherry-pick falló (exit $($r.ExitCode))")
        if ($abortResult.ExitCode -eq 0) {
            $msg = "$msg (abort OK)"
        }
        return @{ Ok = $false; Message = $msg }
    }

    # Devuelve líneas de `git log --graph --oneline --all --decorate -<maxCommits>`.
    # Vacío si el path no es git. Útil para vista de branch graph con scroll H/V.
    [string[]] GetGraph([string]$path, [int]$maxCommits) {
        if (-not $this.IsGitRepo($path)) { return @() }
        if ($maxCommits -le 0) { $maxCommits = 200 }
        $lines = $this.RunGit($path, @('log', '--graph', '--oneline', '--all', '--decorate', "-$maxCommits"))
        if (-not $lines) { return @() }
        return @($lines)
    }

    # Lista de tags ordenados por fecha (más reciente primero).
    [string[]] GetTags([string]$path) {
        if (-not $this.IsGitRepo($path)) { return @() }
        $lines = $this.RunGit($path, @('tag', '-l', '--sort=-committerdate'))
        if (-not $lines) { return @() }
        return @($lines | Where-Object { $_ -and $_.Trim() })
    }

    # Crea un tag annotated. Si no se da mensaje, usa el nombre del tag.
    [hashtable] CreateTag([string]$path, [string]$tagName, [string]$message) {
        if (-not $this.IsGitRepo($path)) {
            return @{ Ok = $false; Message = "no es un repo git: $path" }
        }
        if ([string]::IsNullOrWhiteSpace($tagName)) {
            return @{ Ok = $false; Message = "nombre del tag vacío" }
        }
        $msg = if ([string]::IsNullOrWhiteSpace($message)) { $tagName } else { $message }
        $r = $this.RunGitCapturingStderr($path, @('tag', '-a', $tagName, '-m', $msg))
        if ($r.ExitCode -eq 0) {
            $this.InvalidatePath($path)
            return @{ Ok = $true; Message = "tag $tagName creado" }
        }
        return @{ Ok = $false; Message = $this.FirstStderrLine($r, "tag falló (exit $($r.ExitCode))") }
    }

    # Push de todos los tags al origin.
    [hashtable] PushTags([string]$path) {
        if (-not $this.IsGitRepo($path)) {
            return @{ Ok = $false; Message = "no es un repo git: $path" }
        }
        $r = $this.RunGitCapturingStderr($path, @('push', 'origin', '--tags'))
        if ($r.ExitCode -eq 0) {
            return @{ Ok = $true; Message = "tags pusheados" }
        }
        return @{ Ok = $false; Message = $this.FirstStderrLine($r, "push tags falló (exit $($r.ExitCode))") }
    }

    # Lista de branches remotas formato "origin/<name>" (sin HEAD pointer).
    # Vacío si no hay remotes o el path no es git.
    [string[]] GetRemoteBranches([string]$path) {
        if (-not $this.IsGitRepo($path)) { return @() }
        $lines = $this.RunGit($path, @('for-each-ref', '--format=%(refname:short)', 'refs/remotes/'))
        if (-not $lines) { return @() }
        # Filtrar el HEAD pointer (suele aparecer como "origin/HEAD")
        return @($lines | Where-Object { $_ -and -not $_.EndsWith('/HEAD') })
    }

    # Fetch all remotes. Retorna @{ Ok; Message }. Sin args = todos los remotes
    # con --prune (limpia refs locales de branches borradas en el remote).
    [hashtable] Fetch([string]$path) {
        if (-not $this.IsGitRepo($path)) {
            return @{ Ok = $false; Message = "no es un repo git: $path" }
        }
        $r = $this.RunGitCapturingStderr($path, @('fetch', '--all', '--prune'))
        if ($r.ExitCode -eq 0) {
            $this.InvalidatePath($path)
            $this.NotifyFetched($path)
            return @{ Ok = $true; Message = 'fetch OK' }
        }
        return @{ Ok = $false; Message = $this.FirstStderrLine($r, "fetch falló (exit $($r.ExitCode))") }
    }

    # Stage del file (puede ser ruta relativa o "." para todo).
    [hashtable] Add([string]$path, [string]$file) {
        if (-not $this.IsGitRepo($path)) {
            return @{ Ok = $false; Message = "no es un repo git: $path" }
        }
        if ([string]::IsNullOrWhiteSpace($file)) {
            return @{ Ok = $false; Message = "archivo vacío" }
        }
        $r = $this.RunGitCapturingStderr($path, @('add', '--', $file))
        if ($r.ExitCode -eq 0) {
            $this.InvalidatePath($path)
            return @{ Ok = $true; Message = "add OK" }
        }
        return @{ Ok = $false; Message = $this.FirstStderrLine($r, "add falló (exit $($r.ExitCode))") }
    }

    # Commit de los cambios staged con el mensaje dado.
    [hashtable] Commit([string]$path, [string]$message) {
        if (-not $this.IsGitRepo($path)) {
            return @{ Ok = $false; Message = "no es un repo git: $path" }
        }
        if ([string]::IsNullOrWhiteSpace($message)) {
            return @{ Ok = $false; Message = "mensaje vacío" }
        }
        $r = $this.RunGitCapturingStderr($path, @('commit', '-m', $message))
        if ($r.ExitCode -eq 0) {
            $this.InvalidatePath($path)
            return @{ Ok = $true; Message = "commit OK" }
        }
        return @{ Ok = $false; Message = $this.FirstStderrLine($r, "commit falló (exit $($r.ExitCode))") }
    }

    # Devuelve la URL de Pull Request para una branch dada en GitHub.
    # Si el remote no es GitHub o no hay remote, devuelve string vacío.
    # Maneja ambos formatos de remote URL: HTTPS y SSH.
    [string] GetPullRequestUrl([string]$path, [string]$branchName) {
        if (-not $this.IsGitRepo($path)) { return '' }
        $urlArr = $this.RunGit($path, @('config', '--get', 'remote.origin.url'))
        if (-not $urlArr -or -not $urlArr[0]) { return '' }
        $url = $urlArr[0].Trim()

        # SSH: git@github.com:owner/repo.git → owner/repo
        # HTTPS: https://github.com/owner/repo.git → owner/repo
        $ownerRepo = $null
        if ($url -match 'github\.com[:/]([^/]+/[^/]+?)(\.git)?$') {
            $ownerRepo = $Matches[1]
        }
        if (-not $ownerRepo) { return '' }
        return "https://github.com/$ownerRepo/pull/new/$branchName"
    }

    # Merge de $branchName en la branch actual con --no-edit (no abre editor).
    [hashtable] MergeBranch([string]$path, [string]$branchName) {
        if (-not $this.IsGitRepo($path)) {
            return @{ Ok = $false; Message = "no es un repo git: $path" }
        }
        if ([string]::IsNullOrWhiteSpace($branchName)) {
            return @{ Ok = $false; Message = "nombre de branch vacío" }
        }

        $r = $this.RunGitCapturingStderr($path, @('merge', '--no-edit', $branchName))
        if ($r.ExitCode -eq 0) {
            $this.InvalidatePath($path)
            return @{ Ok = $true; Message = "mergeado: $branchName" }
        }
        return @{ Ok = $false; Message = $this.FirstStderrLine($r, "merge falló — capaz hay conflictos") }
    }

    [void] InvalidateCache() {
        $this.StateCache = @{}
    }

    [void] InvalidatePath([string]$path) {
        $this.StateCache.Remove($path)
    }

    # Ejecuta un comando git y devuelve líneas de stdout. $null si falla o no es repo.
    # Nota: param se llama $cmdArgs (no $args) porque $args choca con la auto-var
    # de PS y rompe el splat `@args`.
    hidden [string[]] RunGit([string]$path, [string[]]$cmdArgs) {
        $prevLocation = $null
        try {
            $prevLocation = Get-Location
            Set-Location -LiteralPath $path -ErrorAction Stop
            # 2>$null para silenciar stderr ruidoso de "not a git repo"
            $result = & git @cmdArgs 2>$null
            $exit = $LASTEXITCODE
            if ($prevLocation) { Set-Location -LiteralPath $prevLocation }
            if ($exit -ne 0) { return $null }
            if ($null -eq $result) { return @() }
            return @($result)
        }
        catch {
            if ($prevLocation) {
                try { Set-Location -LiteralPath $prevLocation } catch { $null = $_ }
            }
            return $null
        }
    }

    # Variante de RunGit que captura stderr y exit code. La usamos en operaciones
    # que mutan estado (checkout, branch, merge) donde el motivo del error importa.
    hidden [hashtable] RunGitCapturingStderr([string]$path, [string[]]$cmdArgs) {
        $prevLocation = $null
        $stderrPath = [System.IO.Path]::GetTempFileName()
        try {
            $prevLocation = Get-Location
            Set-Location -LiteralPath $path -ErrorAction Stop
            $stdout = & git @cmdArgs 2>$stderrPath
            $exit = $LASTEXITCODE
            if ($prevLocation) { Set-Location -LiteralPath $prevLocation }
            $stderr = if (Test-Path -LiteralPath $stderrPath) {
                Get-Content -LiteralPath $stderrPath -Raw -Encoding UTF8
            } else { '' }
            return @{ ExitCode = $exit; Stdout = $stdout; Stderr = $stderr }
        }
        catch {
            if ($prevLocation) {
                try { Set-Location -LiteralPath $prevLocation } catch { $null = $_ }
            }
            return @{ ExitCode = -1; Stdout = $null; Stderr = $_.Exception.Message }
        }
        finally {
            if (Test-Path -LiteralPath $stderrPath) {
                Remove-Item -LiteralPath $stderrPath -Force -ErrorAction SilentlyContinue
            }
        }
    }

    # Helper: extrae primera línea no vacía del stderr de git, fallback al $default
    # si está vacío. Git suele meter la causa en línea 1; el resto suele ser ruido.
    hidden [string] FirstStderrLine([hashtable]$result, [string]$fallback) {
        if ($result.Stderr) {
            $first = ($result.Stderr -split "`n" | Where-Object { $_.Trim() } | Select-Object -First 1)
            if ($first) { return $first.Trim() }
        }
        return $fallback
    }

    hidden [Repo] UpdateCache([string]$path, [Repo]$repo) {
        $this.StateCache[$path] = @{ state = $repo; fetchedAt = (Get-Date) }
        return $repo
    }
}