Last active
March 16, 2026 22:41
-
-
Save kasuken/e128dc563005d13a5065d1d98162eef6 to your computer and use it in GitHub Desktop.
A script to update your local GitHub repositories with the remote branches (and cleanup)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| function Sync-GitHubWorkspace { | |
| [CmdletBinding(SupportsShouldProcess)] | |
| param( | |
| [string]$Path = (Get-Location).Path, | |
| [string[]]$IncludeBranches = @("main", "master", "develop", "dev"), | |
| [switch]$CleanBinObj, | |
| [switch]$AutoStash | |
| ) | |
| function Write-Section { | |
| param([string]$Text) | |
| Write-Host "" | |
| Write-Host "----------------------------------------------------" -ForegroundColor DarkGray | |
| Write-Host $Text -ForegroundColor Cyan | |
| Write-Host "----------------------------------------------------" -ForegroundColor DarkGray | |
| } | |
| function Get-RepoPaths { | |
| param([string]$RootPath) | |
| Get-ChildItem -Path $RootPath -Directory -Force | | |
| Where-Object { | |
| (Test-Path (Join-Path $_.FullName ".git")) -or | |
| (git -C $_.FullName rev-parse --is-inside-work-tree 2>$null) | |
| } | | |
| ForEach-Object { $_.FullName } | | |
| Sort-Object -Unique | |
| } | |
| function Get-CurrentBranch { | |
| param([string]$RepoPath) | |
| $branch = git -C $RepoPath rev-parse --abbrev-ref HEAD 2>$null | |
| if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($branch) -or $branch -eq "HEAD") { | |
| return $null | |
| } | |
| $branch.Trim() | |
| } | |
| function Get-UpstreamBranch { | |
| param([string]$RepoPath) | |
| $upstream = git -C $RepoPath rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>$null | |
| if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($upstream)) { | |
| return $null | |
| } | |
| $upstream.Trim() | |
| } | |
| function Get-WorkingTreeState { | |
| param([string]$RepoPath) | |
| $statusLines = git -C $RepoPath status --porcelain 2>$null | |
| $hasUntracked = $false | |
| $hasTrackedChanges = $false | |
| foreach ($line in $statusLines) { | |
| if ($line -match '^\?\?') { | |
| $hasUntracked = $true | |
| } | |
| else { | |
| $hasTrackedChanges = $true | |
| } | |
| } | |
| [PSCustomObject]@{ | |
| HasChanges = ($hasUntracked -or $hasTrackedChanges) | |
| HasUntracked = $hasUntracked | |
| HasTrackedChanges = $hasTrackedChanges | |
| } | |
| } | |
| function Get-BehindAhead { | |
| param( | |
| [string]$RepoPath, | |
| [string]$Upstream | |
| ) | |
| $result = git -C $RepoPath rev-list --left-right --count "HEAD...$Upstream" 2>$null | |
| if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($result)) { | |
| return $null | |
| } | |
| $parts = $result.Trim() -split '\s+' | |
| if ($parts.Count -ne 2) { | |
| return $null | |
| } | |
| [PSCustomObject]@{ | |
| Ahead = [int]$parts[0] | |
| Behind = [int]$parts[1] | |
| } | |
| } | |
| function Remove-BinObjFolders { | |
| param([string]$RootPath) | |
| Write-Section "Removing bin and obj folders" | |
| $folders = Get-ChildItem -Path $RootPath -Directory -Recurse -Force | | |
| Where-Object { | |
| $_.Name -in @("bin", "obj") -and | |
| $_.FullName -notmatch '[\\/]+node_modules([\\/]|$)' | |
| } | | |
| Sort-Object FullName -Descending | |
| foreach ($folder in $folders) { | |
| if ($PSCmdlet.ShouldProcess($folder.FullName, "Remove folder")) { | |
| Write-Host "Removing: $($folder.FullName)" -ForegroundColor Red | |
| Remove-Item -Path $folder.FullName -Force -Recurse -ErrorAction Continue | |
| } | |
| } | |
| } | |
| $results = New-Object System.Collections.Generic.List[object] | |
| Write-Host "Starting workspace sync in: $Path" -ForegroundColor Green | |
| $repos = Get-RepoPaths -RootPath $Path | |
| foreach ($repo in $repos) { | |
| Write-Section $repo | |
| $result = [PSCustomObject]@{ | |
| Repository = $repo | |
| Branch = $null | |
| Status = $null | |
| Details = $null | |
| } | |
| git -C $repo fetch --prune origin 2>$null | |
| if ($LASTEXITCODE -ne 0) { | |
| $result.Status = "FetchFailed" | |
| $result.Details = "git fetch --prune origin failed" | |
| Write-Host $result.Details -ForegroundColor Red | |
| $results.Add($result) | |
| continue | |
| } | |
| $branch = Get-CurrentBranch -RepoPath $repo | |
| $result.Branch = $branch | |
| if (-not $branch) { | |
| $result.Status = "DetachedHead" | |
| $result.Details = "Detached HEAD or branch not detected" | |
| Write-Host $result.Details -ForegroundColor Yellow | |
| $results.Add($result) | |
| continue | |
| } | |
| Write-Host "Current branch: $branch" -ForegroundColor White | |
| if ($IncludeBranches -notcontains $branch) { | |
| $result.Status = "SkippedBranch" | |
| $result.Details = "Not in allowed branches: $($IncludeBranches -join ', ')" | |
| Write-Host "Not in allowed branches. Skipping." -ForegroundColor Yellow | |
| $results.Add($result) | |
| continue | |
| } | |
| $state = Get-WorkingTreeState -RepoPath $repo | |
| if ($state.HasChanges -and -not $AutoStash) { | |
| if ($state.HasUntracked) { | |
| $result.Status = "SkippedUntracked" | |
| $result.Details = "Untracked files present" | |
| Write-Host "Untracked files present. Skipping." -ForegroundColor DarkYellow | |
| } | |
| elseif ($state.HasTrackedChanges) { | |
| $result.Status = "SkippedDirty" | |
| $result.Details = "Uncommitted changes present" | |
| Write-Host "Uncommitted changes present. Skipping." -ForegroundColor DarkYellow | |
| } | |
| $results.Add($result) | |
| continue | |
| } | |
| $stashCreated = $false | |
| if ($state.HasChanges -and $AutoStash) { | |
| Write-Host "Stashing local changes..." -ForegroundColor DarkYellow | |
| git -C $repo stash push -u -m "Sync-GitHubWorkspace auto-stash" 2>$null | |
| if ($LASTEXITCODE -eq 0) { | |
| $stashCreated = $true | |
| } | |
| } | |
| try { | |
| $upstream = Get-UpstreamBranch -RepoPath $repo | |
| if (-not $upstream) { | |
| $result.Status = "NoUpstream" | |
| $result.Details = "No upstream configured" | |
| Write-Host "No upstream configured. Skipping." -ForegroundColor Yellow | |
| $results.Add($result) | |
| continue | |
| } | |
| Write-Host "Upstream: $upstream" -ForegroundColor Gray | |
| $delta = Get-BehindAhead -RepoPath $repo -Upstream $upstream | |
| if (-not $delta) { | |
| $result.Status = "CompareFailed" | |
| $result.Details = "Could not compare HEAD with upstream" | |
| Write-Host $result.Details -ForegroundColor Red | |
| $results.Add($result) | |
| continue | |
| } | |
| if ($delta.Ahead -eq 0 -and $delta.Behind -eq 0) { | |
| $result.Status = "UpToDate" | |
| $result.Details = "Already up to date" | |
| Write-Host "Already up to date." -ForegroundColor Green | |
| $results.Add($result) | |
| continue | |
| } | |
| if ($delta.Ahead -gt 0 -and $delta.Behind -eq 0) { | |
| $result.Status = "Ahead" | |
| $result.Details = "Local branch ahead by $($delta.Ahead) commit(s)" | |
| Write-Host $result.Details -ForegroundColor Yellow | |
| $results.Add($result) | |
| continue | |
| } | |
| if ($delta.Ahead -gt 0 -and $delta.Behind -gt 0) { | |
| $result.Status = "Diverged" | |
| $result.Details = "Branch diverged. Ahead: $($delta.Ahead), Behind: $($delta.Behind)" | |
| Write-Host $result.Details -ForegroundColor Red | |
| $results.Add($result) | |
| continue | |
| } | |
| if ($delta.Behind -gt 0) { | |
| if ($PSCmdlet.ShouldProcess($repo, "Pull latest changes on $branch")) { | |
| Write-Host "Pulling latest changes..." -ForegroundColor Green | |
| git -C $repo pull --ff-only 2>$null | |
| if ($LASTEXITCODE -eq 0) { | |
| $result.Status = "Updated" | |
| $result.Details = "Pulled $($delta.Behind) commit(s)" | |
| Write-Host "Pull completed." -ForegroundColor Green | |
| } | |
| else { | |
| $result.Status = "PullFailed" | |
| $result.Details = "git pull --ff-only failed" | |
| Write-Host $result.Details -ForegroundColor Red | |
| } | |
| } | |
| else { | |
| $result.Status = "WhatIf" | |
| $result.Details = "Would pull $($delta.Behind) commit(s)" | |
| } | |
| $results.Add($result) | |
| continue | |
| } | |
| } | |
| finally { | |
| if ($stashCreated) { | |
| Write-Host "Restoring stashed changes..." -ForegroundColor DarkYellow | |
| git -C $repo stash pop 2>$null | Out-Null | |
| } | |
| } | |
| } | |
| if ($CleanBinObj) { | |
| Remove-BinObjFolders -RootPath $Path | |
| } | |
| Write-Section "Summary" | |
| $results | | |
| Sort-Object Status, Repository | | |
| Format-Table -AutoSize Repository, Branch, Status, Details | |
| return $results | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment