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 } } |