src/GitStatus.ps1
|
#requires -Version 5.1 # GitStatus.ps1 - Fast git status detection with caching for power-git # # Key improvement over posh-git: uses `git status --porcelain=v2 --branch` # in a SINGLE call to get all information, vs posh-git's multiple git invocations. # Status result object class GitBranchStatus { [string]$Name # Branch name (or commit hash if detached) [bool]$IsDetached # HEAD is detached [string]$Upstream # Upstream tracking branch (or $null) [bool]$UpstreamGone # Upstream branch has been deleted [int]$AheadBy # Commits ahead of upstream [int]$BehindBy # Commits behind upstream [string] ToString() { $uStr = '' if ($this.Upstream) { $uStr = " -> $($this.Upstream) +$($this.AheadBy)/-$($this.BehindBy)" } $dStr = '' if ($this.IsDetached) { $dStr = ' (detached)' } return "$($this.Name)$dStr$uStr" } } class GitIndexStatus { [int]$Added [int]$Modified [int]$Deleted [int]$Renamed [int]$Copied [string] ToString() { return "Added=$($this.Added) Modified=$($this.Modified) Deleted=$($this.Deleted) Renamed=$($this.Renamed) Copied=$($this.Copied)" } } class GitWorkingStatus { [int]$Modified [int]$Deleted [int]$Untracked [int]$Conflicted [string] ToString() { return "Modified=$($this.Modified) Deleted=$($this.Deleted) Untracked=$($this.Untracked) Conflicted=$($this.Conflicted)" } } class GitRepoState { [string]$State # '', 'MERGE', 'REBASE', 'REBASE-i', 'REBASE-m', 'CHERRY-PICK', 'REVERT', 'BISECT' [int]$RebaseStep # Current rebase step (0 if not rebasing) [int]$RebaseTotal # Total rebase steps (0 if not rebasing) [string] ToString() { if (-not $this.State) { return 'clean' } if ($this.RebaseTotal -gt 0) { return "$($this.State) $($this.RebaseStep)/$($this.RebaseTotal)" } return $this.State } } class GitStatus { [bool]$IsGitRepo = $false [string]$GitDir # Path to .git directory [string]$WorkingDir # Path to working tree root [GitBranchStatus]$Branch [GitIndexStatus]$Index [GitWorkingStatus]$Working [GitRepoState]$RepoState [int]$StashCount [bool]$HasUntracked # True if the repo has any changes worth showing [bool] HasChanges() { return ($this.Index.Added -gt 0 -or $this.Index.Modified -gt 0 -or $this.Index.Deleted -gt 0 -or $this.Index.Renamed -gt 0 -or $this.Index.Copied -gt 0 -or $this.Working.Modified -gt 0 -or $this.Working.Deleted -gt 0 -or $this.Working.Untracked -gt 0 -or $this.Working.Conflicted -gt 0) } } # Cache entry class StatusCacheEntry { [string]$Key # Resolved git dir path [GitStatus]$Status [datetime]$FetchedAt [int]$TtlMs [bool] IsExpired() { return ((Get-Date) - $this.FetchedAt).TotalMilliseconds -gt $this.TtlMs } } # Module-level cache (plain hashtable avoids generic type parameter issues in PS 5.1) $script:StatusCache = @{} # Parse `git status --porcelain=v2 --branch` output into a GitStatus object function ConvertFrom-GitPorcelainV2 { [CmdletBinding()] [OutputType([GitStatus])] param( [string[]]$Lines, [string]$GitDir, [string]$WorkingDir ) $status = [GitStatus]::new() $status.IsGitRepo = $true $status.GitDir = $GitDir $status.WorkingDir = $WorkingDir $status.Branch = [GitBranchStatus]::new() $status.Index = [GitIndexStatus]::new() $status.Working = [GitWorkingStatus]::new() $status.RepoState = [GitRepoState]::new() foreach ($line in $Lines) { # Branch header lines begin with '#' if ($line -match '^# branch\.head (.+)$') { $head = $Matches[1] if ($head -eq '(detached)') { $status.Branch.IsDetached = $true # Get short hash for detached HEAD display $hash = & git rev-parse --short HEAD 2>$null $status.Branch.Name = if ($hash) { $hash } else { 'HEAD' } } else { $status.Branch.Name = $head } continue } if ($line -match '^# branch\.upstream (.+)$') { $status.Branch.Upstream = $Matches[1] continue } if ($line -match '^# branch\.ab \+(\d+) -(\d+)$') { $status.Branch.AheadBy = [int]$Matches[1] $status.Branch.BehindBy = [int]$Matches[2] continue } if ($line -eq '# branch.upstream (gone)' -or ($line -match '^# branch\.upstream' -and $status.Branch.Upstream -eq $null)) { # handled above continue } # Detect gone upstream via ab line missing (upstream set, ab not present = gone) # This is handled after parsing by checking if Upstream is set but AheadBy/BehindBy = 0 # and git returns "gone" in the upstream line - porcelain v2 uses special header for this # Changed tracked entry (ordinary) if ($line -match '^1 (\S\S) ') { $xy = $Matches[1] $x = $xy[0] # Index status $y = $xy[1] # Working status $null = Add-XYStatus -Status $status -X $x -Y $y continue } # Renamed/copied entry if ($line -match '^2 (\S\S) ') { $xy = $Matches[1] $x = $xy[0] $y = $xy[1] # Index side if ($x -eq 'R') { $status.Index.Renamed++ } elseif ($x -eq 'C') { $status.Index.Copied++ } # Working side if ($y -ne '.') { $status.Working.Modified++ } continue } # Unmerged (conflict) if ($line -match '^u ') { $status.Working.Conflicted++ continue } # Untracked if ($line -match '^\? ') { $status.Working.Untracked++ $status.HasUntracked = $true continue } } # Detect gone upstream: when upstream is set but no "# branch.ab" line was present, # the remote branch was deleted. git porcelain v2 simply omits the ab line. if ($status.Branch.Upstream -and $status.Branch.AheadBy -eq 0 -and $status.Branch.BehindBy -eq 0 -and -not ($Lines -match '^# branch\.ab')) { # Only mark gone if there truly was no ab line (not just both zero) # git omits the ab line entirely when upstream is gone $hasAbLine = $false foreach ($l in $Lines) { if ($l -match '^# branch\.ab') { $hasAbLine = $true; break } } if (-not $hasAbLine) { $status.Branch.UpstreamGone = $true } } return $status } # Helper: accumulate X/Y status codes into index/working counts function Add-XYStatus { param($Status, [char]$X, [char]$Y) # Index (X column) switch ($X) { 'A' { $Status.Index.Added++ } 'M' { $Status.Index.Modified++ } 'D' { $Status.Index.Deleted++ } 'R' { $Status.Index.Renamed++ } 'C' { $Status.Index.Copied++ } } # Working (Y column) switch ($Y) { 'M' { $Status.Working.Modified++ } 'D' { $Status.Working.Deleted++ } '?' { $Status.Working.Untracked++ } 'U' { $Status.Working.Conflicted++ } } } # Detect special repo states (MERGE, REBASE, etc.) by inspecting the .git directory function Get-RepoState { [CmdletBinding()] [OutputType([GitRepoState])] param([string]$GitDir) $state = [GitRepoState]::new() $state.State = '' # Check for rebase-merge (interactive rebase) $rebaseMerge = Join-Path $GitDir 'rebase-merge' if (Test-Path $rebaseMerge) { $stepFile = Join-Path $rebaseMerge 'msgnum' $totalFile = Join-Path $rebaseMerge 'end' if (Test-Path $stepFile) { $state.RebaseStep = [int](Get-Content $stepFile -Raw).Trim() } if (Test-Path $totalFile) { $state.RebaseTotal = [int](Get-Content $totalFile -Raw).Trim() } $state.State = 'REBASE-i' return $state } # Check for rebase-apply (am/rebase --apply) $rebaseApply = Join-Path $GitDir 'rebase-apply' if (Test-Path $rebaseApply) { $stepFile = Join-Path $rebaseApply 'next' $totalFile = Join-Path $rebaseApply 'last' if (Test-Path $stepFile) { $state.RebaseStep = [int](Get-Content $stepFile -Raw).Trim() } if (Test-Path $totalFile) { $state.RebaseTotal = [int](Get-Content $totalFile -Raw).Trim() } if (Test-Path (Join-Path $rebaseApply 'rebasing')) { $state.State = 'REBASE' } else { $state.State = 'AM' } return $state } if (Test-Path (Join-Path $GitDir 'MERGE_HEAD')) { $state.State = 'MERGE'; return $state } if (Test-Path (Join-Path $GitDir 'CHERRY_PICK_HEAD')) { $state.State = 'CHERRY-PICK'; return $state } if (Test-Path (Join-Path $GitDir 'REVERT_HEAD')) { $state.State = 'REVERT'; return $state } if (Test-Path (Join-Path $GitDir 'BISECT_LOG')) { $state.State = 'BISECT'; return $state } return $state } # Main function: get current git status with optional caching function Get-GitStatus { [CmdletBinding()] [OutputType([GitStatus])] param( [string]$Path = (Get-Location).Path, [switch]$NoCache, [switch]$NoFileStatus ) $settings = $global:PowerGitSettings # Find the git directory $gitDir = & git -C $Path rev-parse --git-dir 2>$null if ($LASTEXITCODE -ne 0 -or -not $gitDir) { return $null # Not in a repo } # Resolve to absolute path if (-not [System.IO.Path]::IsPathRooted($gitDir)) { $gitDir = [System.IO.Path]::GetFullPath((Join-Path $Path $gitDir)) } $workDir = & git -C $Path rev-parse --show-toplevel 2>$null # Check per-repo file status disable setting $showFiles = -not $NoFileStatus if ($showFiles -and $settings -and $settings.DisableFileStatusRepos.Contains($workDir)) { $showFiles = $false } if ($showFiles -and $settings -and -not $settings.ShowFileStatus) { $showFiles = $false } # Cache key = gitdir + fileStatus mode $cacheKey = "${gitDir}|${showFiles}" # Check cache if (-not $NoCache -and $settings -and $settings.StatusCacheMs -gt 0 -and $script:StatusCache.ContainsKey($cacheKey)) { $entry = $script:StatusCache[$cacheKey] if (-not $entry.IsExpired()) { return $entry.Status } } # Build git command $gitArgs = @('status', '--porcelain=v2', '--branch') if (-not $showFiles) { # Only get branch info, skip file status enumeration $gitArgs += '--untracked-files=no' } $lines = & git -C $Path @gitArgs 2>$null if ($LASTEXITCODE -ne 0) { return $null } $status = ConvertFrom-GitPorcelainV2 -Lines $lines -GitDir $gitDir -WorkingDir $workDir # Get repo special state (merge, rebase, etc.) $status.RepoState = Get-RepoState -GitDir $gitDir # Get stash count (fast: count lines in refs/stash) if ($settings -and $settings.ShowStashCount) { $stashRef = Join-Path $gitDir 'refs/stash' if (Test-Path $stashRef) { $stashLines = & git -C $Path stash list --format='%H' 2>$null $status.StashCount = @($stashLines).Count } } # Store in cache if ($settings -and $settings.StatusCacheMs -gt 0) { $entry = [StatusCacheEntry]::new() $entry.Key = $cacheKey $entry.Status = $status $entry.FetchedAt = Get-Date $entry.TtlMs = $settings.StatusCacheMs $script:StatusCache[$cacheKey] = $entry } return $status } # Invalidate the status cache (call after git operations) function Clear-GitStatusCache { [CmdletBinding()] param() $script:StatusCache.Clear() } # Exports are controlled from power-git.psm1 |