Update-Worktrees.ps1
|
function Select-ChangedWorktreeResult { <# .SYNOPSIS Select actionable worktree update results without changing their shape. #> [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] [AllowNull()] [PSObject]$InputObject ) process { if ($null -ne $InputObject -and $InputObject.Status -in @('Updated', 'Removed', 'Failed', 'StashFailed')) { $InputObject } } } function Restore-WorktreeUpdateStash { <# .SYNOPSIS Restore an update's captured stash object, removing only its verified top entry. #> [OutputType([bool])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [ValidatePattern('\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z')] [string]$ObjectId ) $apply = Invoke-Git -Path $Path -Arguments @('stash', 'apply', '--', $ObjectId) -AllowNonZeroExit if ($null -eq $apply) { return $false } if ($apply.ExitCode -ne 0) { Write-Warning "git stash apply failed for saved stash '$ObjectId' in '$Path' (exit $($apply.ExitCode)): $($apply.Output -join [Environment]::NewLine)" return $false } # Apply the immutable object, not a reflog position. If another writer moved # the stack, keep every entry rather than dropping an unrelated newest stash. $head = Invoke-Git -Path $Path -Arguments @( 'for-each-ref', '--format=%(objectname)', '--', 'refs/stash' ) -AllowNonZeroExit if ($null -eq $head) { return $false } if ($head.ExitCode -ne 0) { Write-Warning "Could not verify saved stash '$ObjectId' after applying it in '$Path' (exit $($head.ExitCode)): $($head.Output -join [Environment]::NewLine)" return $false } if ($head.StandardOutput.Trim() -cne $ObjectId) { Write-Warning "Applied saved stash '$ObjectId' in '$Path', but the stash stack changed; no stash was dropped." return $false } $drop = Invoke-Git -Path $Path -Arguments @('stash', 'drop', '--quiet', '--', 'stash@{0}') -AllowNonZeroExit if ($null -eq $drop) { return $false } if ($drop.ExitCode -ne 0) { Write-Warning "git stash drop failed for restored stash '$ObjectId' in '$Path' (exit $($drop.ExitCode)): $($drop.Output -join [Environment]::NewLine)" return $false } $true } function Update-Worktrees { <# .SYNOPSIS Update all worktrees for the repository to the latest from upstream. .DESCRIPTION Fetches from all remotes and fast-forwards worktrees that have zero local commits, saving and restoring any local changes through an owned stash. Returns an object per worktree describing the action taken. Restores only the captured stash object and drops it only when it is still the newest entry. A successful stash push that creates no stash (for example submodule-only changes) leaves the worktree untouched and returns StashFailed. Restoration conflicts retain the stash and report PopFailed. Dirty worktrees are processed sequentially because the stash stack is shared. Avoid other stash writers in any linked worktree while updating: the stash identity reads and verified drop are not atomic with external Git processes. Uses a bulk 'git for-each-ref' call to get ahead/behind counts for all branches in one pass, then checks only worktrees that need merging for local changes or in-progress git operations. Use -ChangedOnly to emit only updated, removed, or failed worktree results. This filters output only; it does not change which worktrees are processed. WhatIf previews and warning/error messages remain visible. .PARAMETER Path Directory inside the git working tree to update. Defaults to the current location. .PARAMETER CheckRemote Also query the remote for branches with no local upstream, reclassifying NoUpstream worktrees so deleted/stale remote branches are detected. .PARAMETER GitHubAccountMap Forwarded to Sync-GitRemote. Maps a repository ("host/owner" or bare "owner") to the `gh` account that should fetch it when several accounts are signed in for the host (github.com or a GitHub Enterprise host). .PARAMETER GitHubAccountResolver Forwarded to Sync-GitRemote. A scriptblock that receives the remote's host and owner and returns the `gh` account name to use. .PARAMETER NoGitHubAccountResolve Forwarded to Sync-GitRemote. Disable GitHub account awareness during the fetch; behave exactly as plain 'git fetch'. .PARAMETER ChangedOnly Emit only WorktreeUpdateResult objects with Status Updated, Removed, Failed, or StashFailed. Omit this switch to return every result. WhatIf still shows the standard fetch and fast-forward previews without reporting updates. .EXAMPLE Update-Worktrees Fetches and fast-forwards all worktrees, returning status objects. .EXAMPLE Update-Worktrees | Where-Object Status -eq 'Removed' Returns only worktrees whose upstream branch is gone. .EXAMPLE Update-Worktrees | Format-Table Branch, Status, BehindBy Shows a summary table of all worktree update results. .EXAMPLE Update-Worktrees -CheckRemote Also checks the remote for NoUpstream branches to detect stale branches. .EXAMPLE Update-Worktrees -ChangedOnly Returns only updated, removed, or failed worktrees. .EXAMPLE Update-Worktrees -ChangedOnly -WhatIf Previews fetching and fast-forwarding without performing either operation. #> [OutputType('WorktreeUpdateResult')] [CmdletBinding(SupportsShouldProcess)] param( [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('RepositoryPath', 'RepoPath')] [string]$Path, [switch]$CheckRemote, [hashtable]$GitHubAccountMap, [scriptblock]$GitHubAccountResolver, [switch]$NoGitHubAccountResolve, [switch]$ChangedOnly ) process { $repoPath = Resolve-GitRepositoryPath -Path $Path if (-not $repoPath) { return } $previousView = $PSStyle.Progress.View try { if ($VerbosePreference -eq 'Continue') { $PSStyle.Progress.View = 'Classic' } # Get latest state Write-Progress -Activity 'Updating Worktrees' -Status 'Fetching' -PercentComplete 0 -Id 0 $syncParams = @{} if ($PSBoundParameters.ContainsKey('GitHubAccountMap')) { $syncParams.GitHubAccountMap = $GitHubAccountMap } if ($PSBoundParameters.ContainsKey('GitHubAccountResolver')) { $syncParams.GitHubAccountResolver = $GitHubAccountResolver } if ($NoGitHubAccountResolve) { $syncParams.NoGitHubAccountResolve = $true } $syncParams.Path = $repoPath $fetchResults = Sync-GitRemote @syncParams if (-not $?) { return } # Build set of branch names whose remote refs were just pruned $prunedBranches = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($fr in $fetchResults) { if ($fr.Action -eq 'Deleted' -and $fr.Ref -match '^[^/]+/(.+)$') { $prunedBranches.Add($Matches[1]) | Out-Null } } Write-Progress -Activity 'Updating Worktrees' -Status 'Getting Worktrees' -PercentComplete 0 -Id 0 $worktrees = Get-Worktrees -Path $repoPath if ($null -eq $worktrees -or @($worktrees).Count -eq 0) { Write-Progress -Activity 'Updating Worktrees' -Id 0 -Completed return } # Bulk-fetch ahead/behind counts for all branches in one git call $branchStatus = @{} $refLines = git -C $repoPath for-each-ref --format='%(refname:short)|%(upstream:short)|%(upstream:track)' refs/heads/ 2>&1 foreach ($line in $refLines) { $parts = $line -split '\|', 3 if ($parts.Count -lt 3) { continue } $branch = $parts[0] $upstream = $parts[1] $track = $parts[2] $ahead = 0; $behind = 0; $gone = $false if ($track -match '\[gone\]') { $gone = $true } else { if ($track -match 'ahead (\d+)') { $ahead = [int]$Matches[1] } if ($track -match 'behind (\d+)') { $behind = [int]$Matches[1] } } $branchStatus[$branch] = @{ Ahead = $ahead; Behind = $behind; Gone = $gone; HasUpstream = ($upstream -ne '') } } $results = [System.Collections.Generic.List[PSObject]]::new() $behindWorktrees = [System.Collections.Generic.List[PSObject]]::new() # Classify all worktrees using bulk data (no cd or git calls needed) foreach ($worktree in $worktrees) { $branch = $worktree.Branch $bs = $branchStatus[$branch] if ($null -eq $bs -or -not $bs.HasUpstream) { # No upstream configured — check if the remote ref was just pruned $status = if ($prunedBranches.Contains($branch)) { Write-Verbose "$branch upstream removed (pruned this fetch)" 'Removed' } else { 'NoUpstream' } $results.Add([PSCustomObject]@{ PSTypeName = 'WorktreeUpdateResult' Branch = $branch Path = $worktree.Path Status = $status BehindBy = 0 Stashed = $false Operation = $null }) } elseif ($bs.Gone) { Write-Verbose "$branch upstream removed" $results.Add([PSCustomObject]@{ PSTypeName = 'WorktreeUpdateResult' Branch = $branch Path = $worktree.Path Status = 'Removed' BehindBy = 0 Stashed = $false Operation = $null }) } elseif ($bs.Ahead -gt 0) { $results.Add([PSCustomObject]@{ PSTypeName = 'WorktreeUpdateResult' Branch = $branch Path = $worktree.Path Status = 'Skipped' BehindBy = $bs.Behind Stashed = $false Operation = $null }) } elseif ($bs.Behind -eq 0) { $results.Add([PSCustomObject]@{ PSTypeName = 'WorktreeUpdateResult' Branch = $branch Path = $worktree.Path Status = 'Current' BehindBy = 0 Stashed = $false Operation = $null }) } else { # Behind only — collect for parallel merge $behindWorktrees.Add([PSCustomObject]@{ Branch = $branch Path = $worktree.Path Behind = $bs.Behind }) } } # If -CheckRemote, reclassify NoUpstream branches by checking the remote if ($CheckRemote) { $noUpstream = @($results | Where-Object { $_.Status -eq 'NoUpstream' }) if ($noUpstream.Count -gt 0) { Write-Progress -Activity 'Updating Worktrees' -Status 'Checking remote refs' -PercentComplete 40 -Id 0 # A failed `ls-remote` (e.g. an unreachable remote) returns an # empty ref set; reclassifying from that would wrongly mark every # NoUpstream worktree as Removed. Skip the reclassification and # warn instead of guessing from nothing. $remoteRefs = git -C $repoPath --no-pager ls-remote --heads origin 2>&1 if ($LASTEXITCODE -ne 0) { $detail = ($remoteRefs | ForEach-Object { "$_" }) -join ' ' Write-Warning "Skipping remote branch check: git ls-remote failed for 'origin' (exit $LASTEXITCODE): $detail. NoUpstream worktrees left unclassified." } else { $remoteRefSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($refLine in $remoteRefs) { if ($refLine -match '\trefs/heads/(.+)$') { $remoteRefSet.Add($Matches[1]) | Out-Null } } foreach ($entry in $noUpstream) { if (-not $remoteRefSet.Contains($entry.Branch)) { $entry.Status = 'Removed' Write-Verbose "$($entry.Branch) not found on remote" } } } } } # Merge behind worktrees. Clean worktrees can fast-forward in parallel, but # dirty worktrees must save/restore sequentially because refs/stash is # shared across every worktree in the repository. if ($behindWorktrees.Count -gt 0 -and $PSCmdlet.ShouldProcess("$($behindWorktrees.Count) worktrees", 'Fast-forward merge from upstream')) { Write-Progress -Activity 'Updating Worktrees' -Status "Checking $($behindWorktrees.Count) worktrees" -PercentComplete 50 -Id 0 $cleanWorktrees = [System.Collections.Generic.List[PSObject]]::new() $dirtyWorktrees = [System.Collections.Generic.List[PSObject]]::new() $mergeResults = [System.Collections.Generic.List[PSObject]]::new() foreach ($wt in $behindWorktrees) { $statusSummary = Get-GitStatusSummary -Path $wt.Path if ($statusSummary.Operation) { $mergeResults.Add([PSCustomObject]@{ PSTypeName = 'WorktreeUpdateResult' Branch = $wt.Branch Path = $wt.Path Status = 'InProgress' BehindBy = $wt.Behind Stashed = $false Operation = $statusSummary.Operation PopFailed = $false }) continue } $dirtyOutput = git -C $wt.Path status --porcelain 2>&1 $isDirty = $dirtyOutput -and @($dirtyOutput).Count -gt 0 if ($isDirty) { $dirtyWorktrees.Add($wt) } else { $cleanWorktrees.Add($wt) } } if ($cleanWorktrees.Count -gt 0) { Write-Progress -Activity 'Updating Worktrees' -Status "Merging $($cleanWorktrees.Count) clean worktrees" -PercentComplete 60 -Id 0 $cleanResults = $cleanWorktrees | ForEach-Object -Parallel { $wt = $_ git -C $wt.Path merge --ff-only '@{upstream}' --quiet 2>&1 | Out-Null $mergeSuccess = $LASTEXITCODE -eq 0 [PSCustomObject]@{ PSTypeName = 'WorktreeUpdateResult' Branch = $wt.Branch Path = $wt.Path Status = if ($mergeSuccess) { 'Updated' } else { 'Failed' } BehindBy = $wt.Behind Stashed = $false Operation = $null PopFailed = $false } } -ThrottleLimit 4 foreach ($cr in $cleanResults) { $mergeResults.Add($cr) } } if ($dirtyWorktrees.Count -gt 0) { Write-Progress -Activity 'Updating Worktrees' -Status "Merging $($dirtyWorktrees.Count) dirty worktrees" -PercentComplete 70 -Id 0 } foreach ($wt in $dirtyWorktrees) { $stash = $null $stashErrors = @() $dirtyOutput = git -C $wt.Path status --porcelain 2>&1 $isDirty = $dirtyOutput -and @($dirtyOutput).Count -gt 0 if ($isDirty) { # Reject another writer's stash if it wins the post-push identity read. $stashMessage = "Update-Worktrees $([guid]::NewGuid().ToString('N'))" $stash = Save-GitStash -Path $wt.Path -IncludeUntracked -Message $stashMessage ` -Confirm:$false -ErrorAction SilentlyContinue -ErrorVariable stashErrors if ($stash -and -not $stash.Subject.EndsWith($stashMessage, [StringComparison]::Ordinal)) { $stashErrors += 'The recorded stash was not created by this update; the stash stack may have changed' $stash = $null } } $stashed = $null -ne $stash # A successful no-op push owns no stash, just like a failed push. if ($isDirty -and -not $stashed) { $detail = if ($stashErrors.Count -gt 0) { "git stash push failed for $($wt.Branch): $($stashErrors -join ' ')" } else { "git stash push did not create a stash for $($wt.Branch)" } Write-Warning "$detail; skipped fast-forward to avoid disturbing the working tree." $mergeResults.Add([PSCustomObject]@{ PSTypeName = 'WorktreeUpdateResult' Branch = $wt.Branch Path = $wt.Path Status = 'StashFailed' BehindBy = $wt.Behind Stashed = $false Operation = $null PopFailed = $false }) continue } git -C $wt.Path merge --ff-only '@{upstream}' --quiet 2>&1 | Out-Null $mergeSuccess = $LASTEXITCODE -eq 0 if ($stashed) { $popFailed = -not (Restore-WorktreeUpdateStash -Path $wt.Path -ObjectId $stash.ObjectId) } $mergeResults.Add([PSCustomObject]@{ PSTypeName = 'WorktreeUpdateResult' Branch = $wt.Branch Path = $wt.Path Status = if ($mergeSuccess) { 'Updated' } else { 'Failed' } BehindBy = $wt.Behind Stashed = $stashed Operation = $null PopFailed = if ($stashed) { $popFailed } else { $false } }) } foreach ($mr in $mergeResults) { if ($mr.PopFailed) { Write-Warning "git stash restoration failed for $($mr.Branch) — stash may need manual resolution" } if ($mr.Status -eq 'Updated') { Write-Verbose "Updated $($mr.Branch)" } elseif ($mr.Status -eq 'Failed') { Write-Warning "Fast-forward failed for $($mr.Branch)" } elseif ($mr.Status -eq 'InProgress') { Write-Warning "Skipped $($mr.Branch): git operation in progress ($($mr.Operation))" } $results.Add($mr) } } Write-Progress -Activity 'Updating Worktrees' -Id 0 -Completed if ($ChangedOnly) { $results | Select-ChangedWorktreeResult } else { $results } } finally { $PSStyle.Progress.View = $previousView } } } |