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'. .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. .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. #> [OutputType('GitFetchResult')] [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'All')] param( [Parameter(ParameterSetName = 'Specific', Position = 0)] [ValidateNotNullOrEmpty()] [string]$Remote, [switch]$NoPrune ) $fetchArgs = @('fetch') if ($PSCmdlet.ParameterSetName -eq 'Specific') { $fetchArgs += $Remote } else { $fetchArgs += '--all' } if (-not $NoPrune) { $fetchArgs += '--prune' } $target = if ($Remote) { "remote '$Remote'" } else { 'all remotes' } if ($PSCmdlet.ShouldProcess($target, 'git fetch')) { # Run without --quiet to capture change lines; stderr has the ref updates $output = git @fetchArgs 2>&1 if ($LASTEXITCODE -ne 0) { Write-Error "git fetch failed (exit code $LASTEXITCODE)." 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 '^\s+[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 } } } |