Public/Sync-GitRemote.ps1
|
function Sync-GitRemote { <# .SYNOPSIS Fetch latest state from git remotes. .DESCRIPTION Wraps 'git fetch' with typed parameters. By default fetches from all remotes with --prune to clean up deleted remote branches. Returns GitFetchResult objects describing what changed (new, updated, deleted, or forced-update refs). When a remote branch is pruned, any matching local branch that lacks tracking configuration gets branch.X.remote and branch.X.merge set automatically. This ensures git reports the branch as [gone] rather than simply missing its upstream, enabling reliable detection in prompts, Update-Worktrees, and 'git branch -vv'. GitHub account awareness. When more than one account is signed in to the `gh` CLI for a remote's host (github.com or a GitHub Enterprise host), the fetch for that remote is run with the account that can access it. A per-account token is acquired with `gh auth token` and injected into the git child process environment only; the globally-active `gh` account is never changed and concurrent fetches never cross-contaminate credentials. An account is chosen by consulting -GitHubAccountMap, then -GitHubAccountResolver, then a reactive fallback that tries the active account and retries the remaining signed-in accounts on an auth failure (caching the winning host+owner -> account for the session). This is a graceful no-op — identical to plain 'git fetch' — when `gh` is not installed, only one account is signed in for the host, the host is one `gh` does not manage, or `gh auth token` fails. When account resolution engages, each remote is fetched individually rather than through a single 'git fetch --all', so the returned refs are the same but their ordering groups by remote. .PARAMETER Remote Fetch from a specific remote instead of all remotes. .PARAMETER NoPrune Skip removing remote-tracking references that no longer exist on the remote. By default, deleted remote branches are pruned. .PARAMETER GitHubAccountMap A hashtable mapping a repository to the `gh` account that should fetch it. Keys are "host/owner" (e.g. 'github.com/contoso') or a bare "owner" that applies on any host; values are `gh` account names. Consulted first when choosing an account. .PARAMETER GitHubAccountResolver A scriptblock that receives the remote's host and owner (as two arguments, also available as $args[0]/$args[1]) and returns the `gh` account name to use, or nothing to defer. Consulted after -GitHubAccountMap. .PARAMETER NoGitHubAccountResolve Disable GitHub account awareness entirely; fetch exactly as plain 'git fetch' regardless of how many `gh` accounts are signed in. .EXAMPLE Sync-GitRemote Fetches from all remotes with pruning. Returns nothing if up to date. .EXAMPLE Sync-GitRemote | Format-Table Shows a table of new, updated, and deleted remote refs. .EXAMPLE Sync-GitRemote -Remote origin -NoPrune Fetches from origin without pruning deleted branches. .EXAMPLE Sync-GitRemote -GitHubAccountMap @{ 'github.com/contoso' = 'work-user' } Fetches, using the 'work-user' gh account for any github.com/contoso remote. #> [OutputType('GitFetchResult')] [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'All')] param( [Parameter(ParameterSetName = 'Specific', Position = 0)] [ValidateNotNullOrEmpty()] [string]$Remote, [switch]$NoPrune, [hashtable]$GitHubAccountMap, [scriptblock]$GitHubAccountResolver, [switch]$NoGitHubAccountResolve ) if ($null -eq $script:GitHubAccountCache) { $script:GitHubAccountCache = [System.Collections.Concurrent.ConcurrentDictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) } $target = if ($Remote) { "remote '$Remote'" } else { 'all remotes' } if (-not $PSCmdlet.ShouldProcess($target, 'git fetch')) { return } $pruneArg = if ($NoPrune) { @() } else { @('--prune') } # Decide whether GitHub account awareness can apply at all. When it can't, # fall through to the original single 'git fetch' path unchanged. Only pay # for remote enumeration when a choice is actually possible: a caller-supplied # map/resolver, or a host with more than one signed-in account. A lone account # (the common case) uses the default credential helper exactly as before. $accounts = if ($NoGitHubAccountResolve) { @() } else { @(Get-GitHubSignedInAccount) } $resolvePlan = @{} $remoteSet = @() $canResolve = $accounts.Count -gt 0 -and ( $GitHubAccountMap -or $GitHubAccountResolver -or @($accounts | Group-Object Host | Where-Object { $_.Count -gt 1 }).Count -gt 0 ) if ($canResolve) { $remoteSet = if ($Remote) { @($Remote) } else { @(git remote 2>$null) } foreach ($remoteName in $remoteSet) { if (-not $remoteName) { continue } $url = git remote get-url $remoteName 2>$null if (-not $url) { continue } $info = Get-GitHubRemoteInfo -Url "$url" if (-not $info) { continue } $hostAccounts = @($accounts | Where-Object { $_.Host -eq $info.Host }) if ($hostAccounts.Count -eq 0) { continue } # host gh does not manage $explicit = Get-GitHubAccountMapValue -Map $GitHubAccountMap -HostName $info.Host -Owner $info.Owner if (-not $explicit -and $GitHubAccountResolver) { $explicit = "$(& $GitHubAccountResolver $info.Host $info.Owner)".Trim() } $cacheKey = "$($info.Host)/$($info.Owner)" $cached = $null [void]$script:GitHubAccountCache.TryGetValue($cacheKey, [ref]$cached) # Only take over the fetch when there is a real choice to make: # an explicit mapping, a remembered winner, or more than one account # on the host. A lone account with no mapping behaves as today. if ($explicit -or $cached -or $hostAccounts.Count -gt 1) { $resolvePlan[$remoteName] = [PSCustomObject]@{ Info = $info CacheKey = $cacheKey Explicit = $explicit Cached = $cached HostAccounts = $hostAccounts } } } } $output = $null $failed = $false $gitDirectoryArgs = @('-C', (Get-Location).ProviderPath) if ($resolvePlan.Count -eq 0) { # Original behavior: one 'git fetch' for the whole request. $fetchArgs = $gitDirectoryArgs + @('fetch') $fetchArgs += if ($Remote) { $Remote } else { '--all' } $fetchArgs += $pruneArg $result = Invoke-GitWithEnvironment -Arguments $fetchArgs -Environment (Get-GitFetchParseEnvironment) $output = $result.Output if ($result.ExitCode -ne 0) { $failed = $true } } else { # Per-remote mode: tokened fetch for planned remotes, plain fetch for # the rest. Aggregate all output for the shared parser below. $collected = [System.Collections.Generic.List[string]]::new() foreach ($remoteName in $remoteSet) { if (-not $remoteName) { continue } $baseArgs = $gitDirectoryArgs + @('fetch', $remoteName) + $pruneArg if ($resolvePlan.ContainsKey($remoteName)) { $result = Invoke-GitHubTokenedFetch -Arguments $baseArgs -Plan $resolvePlan[$remoteName] } else { $result = Invoke-GitWithEnvironment -Arguments $baseArgs -Environment (Get-GitFetchParseEnvironment) } foreach ($l in $result.Output) { $collected.Add($l) } if ($result.ExitCode -ne 0) { $failed = $true Write-Error "git fetch failed for '$remoteName' (exit code $($result.ExitCode))." } } $output = $collected.ToArray() } if ($failed -and $resolvePlan.Count -eq 0) { Write-Error "git fetch failed (exit code $($result.ExitCode))." return } foreach ($line in $output) { $text = "$line".Trim() # Parse git fetch output lines: # * [new branch] feature -> origin/feature # * [new tag] v1.0 -> v1.0 # - [deleted] (none) -> origin/old-branch # abc1234..def5678 main -> origin/main # + abc1234..def5678 main -> origin/main (forced update) if ($text -match '^\*\s+\[new (branch|tag)\]\s+\S+\s+->\s+(\S+)') { [PSCustomObject]@{ PSTypeName = 'GitFetchResult' Action = "New $($Matches[1])" Ref = $Matches[2] Summary = $text } } elseif ($text -match '^-\s+\[deleted\]\s+.*->\s+(\S+)') { $deletedRef = $Matches[1] [PSCustomObject]@{ PSTypeName = 'GitFetchResult' Action = 'Deleted' Ref = $deletedRef Summary = $text } # Set tracking config on orphaned local branches so [gone] detection works. # When a remote ref is pruned, local branches that tracked it lose their # upstream pointer if they never had explicit tracking config. if ($deletedRef -match '^([^/]+)/(.+)$') { $remoteName = $Matches[1] $branchName = $Matches[2] $existingRemote = git config --get "branch.$branchName.remote" 2>$null if (-not $existingRemote -and (git rev-parse --verify "refs/heads/$branchName" 2>$null)) { git config "branch.$branchName.remote" $remoteName git config "branch.$branchName.merge" "refs/heads/$branchName" Write-Verbose "Set tracking config on '$branchName' -> '$remoteName/$branchName' (pruned) for [gone] detection" } } } elseif ($text -match '^\+\s+\S+\s+\S+\s+->\s+(\S+)') { [PSCustomObject]@{ PSTypeName = 'GitFetchResult' Action = 'Forced update' Ref = $Matches[1] Summary = $text } } elseif ($text -match '^[0-9a-f]+\.\.[0-9a-f]+\s+\S+\s+->\s+(\S+)') { [PSCustomObject]@{ PSTypeName = 'GitFetchResult' Action = 'Updated' Ref = $Matches[1] Summary = $text } } # Skip "Fetching <remote>" and "From <url>" header lines } } function Get-GitFetchParseEnvironment { <# .SYNOPSIS Return locale environment variables that keep parsed git fetch output stable. #> [OutputType([hashtable])] [CmdletBinding()] param([hashtable]$Environment) $merged = @{} if ($Environment) { foreach ($key in $Environment.Keys) { $merged[$key] = $Environment[$key] } } $merged['LC_ALL'] = 'C' $merged['LANG'] = 'C' $merged } function Invoke-GitHubTokenedFetch { <# .SYNOPSIS Fetch a single remote using a gh account that can access it. .DESCRIPTION Tries candidate accounts in priority order (explicit mapping, then the session-cached winner, then the host's active account, then the rest), acquiring each account's token and running git with GH_TOKEN/GH_HOST set on the child only. Stops at the first success (caching it) or at a non-auth error; keeps trying on auth/access failures. Never changes the active account. Returns a GitInvocationResult. #> [OutputType('GitInvocationResult')] [CmdletBinding()] param( [Parameter(Mandatory)][string[]]$Arguments, [Parameter(Mandatory)][psobject]$Plan ) $gitHost = $Plan.Info.Host # Build the ordered, de-duplicated candidate list. $ordered = [System.Collections.Generic.List[string]]::new() $add = { param($name) if ($name -and -not ($ordered -contains $name)) { $ordered.Add($name) } } & $add $Plan.Explicit & $add $Plan.Cached foreach ($a in ($Plan.HostAccounts | Where-Object Active)) { & $add $a.Account } foreach ($a in $Plan.HostAccounts) { & $add $a.Account } $last = $null foreach ($account in $ordered) { $token = Get-GitHubAccountToken -HostName $gitHost -Account $account if (-not $token) { continue } $result = Invoke-GitWithEnvironment -Arguments $Arguments -Environment (Get-GitFetchParseEnvironment @{ GH_TOKEN = $token GH_HOST = $gitHost # The fallback deliberately fetches with accounts that may be denied; # forbid any interactive credential prompt so a redirected git can # never hang and a denied account fails fast to the next candidate. GIT_TERMINAL_PROMPT = '0' GCM_INTERACTIVE = 'never' }) $last = $result if ($result.ExitCode -eq 0) { $script:GitHubAccountCache[$Plan.CacheKey] = $account Write-Verbose "Fetched $($Plan.CacheKey) using gh account '$account'." return $result } if (-not (Test-GitHubAuthFailure -Output $result.Output)) { # A real error (not an access problem) — don't burn other accounts. return $result } Write-Verbose "gh account '$account' cannot access $($Plan.CacheKey); trying next." } if ($null -ne $last) { return $last } # No usable token for any candidate: fall back to a plain fetch so behavior # matches a machine where account resolution never engaged. Invoke-GitWithEnvironment -Arguments $Arguments -Environment (Get-GitFetchParseEnvironment) } |