Public/Update-Worktrees.ps1

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, stashing and then popping any local changes. Returns an object
    per worktree describing the action taken.
 
    Uses a bulk 'git for-each-ref' call to get ahead/behind counts for all
    branches in one pass, then only runs 'git status' on worktrees that
    actually need merging (behind with no local commits).
    .PARAMETER CheckRemote
    Also query the remote for branches with no local upstream, reclassifying
    NoUpstream worktrees so deleted/stale remote branches are detected.
    .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.
    #>

    [OutputType('WorktreeUpdateResult')]
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [switch]$CheckRemote
    )
    process {
        $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
        $fetchResults = Sync-GitRemote
        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
        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 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
                })
            }
            elseif ($bs.Gone) {
                Write-Verbose "$branch upstream removed"
                $results.Add([PSCustomObject]@{
                    PSTypeName = 'WorktreeUpdateResult'
                    Branch     = $branch
                    Path       = $worktree.Path
                    Status     = 'Removed'
                    BehindBy   = 0
                    Stashed    = $false
                })
            }
            elseif ($bs.Ahead -gt 0) {
                $results.Add([PSCustomObject]@{
                    PSTypeName = 'WorktreeUpdateResult'
                    Branch     = $branch
                    Path       = $worktree.Path
                    Status     = 'Skipped'
                    BehindBy   = $bs.Behind
                    Stashed    = $false
                })
            }
            elseif ($bs.Behind -eq 0) {
                $results.Add([PSCustomObject]@{
                    PSTypeName = 'WorktreeUpdateResult'
                    Branch     = $branch
                    Path       = $worktree.Path
                    Status     = 'Current'
                    BehindBy   = 0
                    Stashed    = $false
                })
            }
            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
                $remoteRefSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
                $remoteRefs = git --no-pager ls-remote --heads origin 2>$null
                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 in parallel
        if ($behindWorktrees.Count -gt 0 -and $PSCmdlet.ShouldProcess("$($behindWorktrees.Count) worktrees", 'Fast-forward merge from upstream')) {
            Write-Progress -Activity 'Updating Worktrees' -Status "Merging $($behindWorktrees.Count) worktrees" -PercentComplete 50 -Id 0
            $mergeResults = $behindWorktrees | ForEach-Object -Parallel {
                $wt = $_
                Push-Location $wt.Path
                try {
                    $stashed = $false
                    $dirtyOutput = git status --porcelain 2>&1
                    $isDirty = $dirtyOutput -and @($dirtyOutput).Count -gt 0

                    if ($isDirty) {
                        git stash push --include-untracked --quiet 2>&1 | Out-Null
                        $stashed = $LASTEXITCODE -eq 0
                    }

                    # Only fast-forward when the tree is safe: either it was clean, or we
                    # successfully stashed it. A dirty tree that failed to stash must NOT be
                    # fast-forwarded, and we must NOT run `git stash pop` (which would pop an
                    # unrelated, pre-existing stash into this worktree).
                    if ($isDirty -and -not $stashed) {
                        [PSCustomObject]@{
                            PSTypeName = 'WorktreeUpdateResult'
                            Branch     = $wt.Branch
                            Path       = $wt.Path
                            Status     = 'StashFailed'
                            BehindBy   = $wt.Behind
                            Stashed    = $false
                            PopFailed  = $false
                        }
                        return
                    }

                    git merge --ff-only '@{upstream}' --quiet 2>&1 | Out-Null
                    $mergeSuccess = $LASTEXITCODE -eq 0

                    if ($stashed) {
                        git stash pop --quiet 2>&1 | Out-Null
                        $popFailed = $LASTEXITCODE -ne 0
                    }

                    [PSCustomObject]@{
                        PSTypeName = 'WorktreeUpdateResult'
                        Branch     = $wt.Branch
                        Path       = $wt.Path
                        Status     = if ($mergeSuccess) { 'Updated' } else { 'Failed' }
                        BehindBy   = $wt.Behind
                        Stashed    = $stashed
                        PopFailed  = if ($stashed) { $popFailed } else { $false }
                    }
                } finally {
                    Pop-Location
                }
            } -ThrottleLimit 4

            foreach ($mr in $mergeResults) {
                if ($mr.PopFailed) {
                    Write-Warning "git stash pop 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 'StashFailed') {
                    Write-Warning "git stash push failed for $($mr.Branch); skipped fast-forward to avoid disturbing the working tree"
                }
                $results.Add($mr)
            }
        }

        Write-Progress -Activity 'Updating Worktrees' -Id 0 -Completed

        $results
        }
        finally {
            $PSStyle.Progress.View = $previousView
        }
    }
}