scripts/win/git/log.ps1


<#
.SYNOPSIS
  Block-style, colored git log for Borg (last N commits) with keyboard navigation.
#>


[CmdletBinding()]
param(
  [Parameter(Position=0)]
  [int]$Count = 5
)

# Allow positional "b gl 20" even if a wrapper passes raw args.
if (-not $PSBoundParameters.ContainsKey('Count') -and $args.Count -ge 1) {
  if ($args[0] -as [int]) { $Count = [int]$args[0] }
}

# sanitize count
if ($Count -lt 1) { $Count = 5 }
if ($Count -gt 200) { $Count = 200 }

function Wrap-Text([string]$text, [int]$width) {
  if ([string]::IsNullOrWhiteSpace($text)) { return @("") }
  $words = $text -split '\s+'
  $lines = New-Object System.Collections.Generic.List[string]
  $line = ""
  foreach ($w in $words) {
    if (($line.Length + $w.Length + 1) -le $width) {
      if ($line.Length -eq 0) { $line = $w } else { $line += " $w" }
    } else {
      $lines.Add($line)
      $line = $w
    }
  }
  if ($line.Length -gt 0) { $lines.Add($line) }
  return $lines
}

function Simplify-Deco([string]$d) {
  if ([string]::IsNullOrWhiteSpace($d)) { return "" }
  $d = $d.Trim()
  if ($d.StartsWith("(") -and $d.EndsWith(")")) { $d = $d.Substring(1, $d.Length - 2) }
  $parts = $d.Split(",") | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }
  $keep = New-Object System.Collections.Generic.List[string]
  foreach ($p in $parts) {
    if ($p -match 'HEAD') { [void]$keep.Add('HEAD'); continue }
    if ($p -match '^tag:') { [void]$keep.Add($p); continue }
    if ($p -notmatch '^origin\/') {
      if ($p -notmatch 'HEAD') { [void]$keep.Add($p) }
    }
  }
  $seen = New-Object System.Collections.Generic.HashSet[string]
  $out = New-Object System.Collections.Generic.List[string]
  foreach ($k in $keep) { if ($seen.Add($k)) { [void]$out.Add($k) } }
  if ($out.Count -eq 0) { return "" }
  return ($out -join ", ")
}

# --- ensure git/repo ---
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Write-Host "git not found in PATH." -ForegroundColor Red; exit 1 }
$top = git rev-parse --show-toplevel 2>$null
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($top)) { Write-Host "Not inside a git repository." -ForegroundColor Red; exit 2 }
Set-Location $top | Out-Null

# header info from porcelain v2 -b
$porcelainB = git status --porcelain=v2 -b
$branch = ""; $upstream = ""; $ahead = 0; $behind = 0
foreach ($l in $porcelainB) {
  if ($l -match '^\#\sbranch\.head\s+(.*)$') { $branch = $Matches[1].Trim(); continue }
  if ($l -match '^\#\sbranch\.upstream\s+(.*)$') { $upstream = $Matches[1].Trim(); continue }
  if ($l -match '^\#\sbranch\.ab\s+\+(\d+)\s\-(\d+)$') { $ahead = [int]$Matches[1]; $behind = [int]$Matches[2]; continue }
}
if (-not $branch) { $branch = (git rev-parse --abbrev-ref HEAD).Trim() }

# --- collect commits ---
$fmt = "%h%x1f%s%x1f%an%x1f%ad%x1f%D%x1f%P%x1e"
$raw = git log --decorate=short --date=relative --pretty=format:"$fmt" -n $Count
$records = ($raw -split "\x1e") | Where-Object { $_ -and $_.Trim() -ne "" }

# --- pre-render blocks into colored lines ---
$lines = New-Object System.Collections.Generic.List[object]

try { $win = $Host.UI.RawUI.WindowSize } catch { $win = @{ Width = 100; Height = 30 } }
$sepWidth = [Math]::Max(40, $win.Width - 0)
$wrapWidth = [Math]::Max(40, $win.Width - 12)

$lines.Add([pscustomobject]@{ Text = ("Repo : {0}" -f $top); Color = 'Cyan' })
$branchLine = $branch
if ($upstream) { $branchLine += " (upstream: $upstream, ↑$ahead ↓$behind)" }
$lines.Add([pscustomobject]@{ Text = ("Branch : {0}" -f $branchLine); Color = 'Cyan' })
$lines.Add([pscustomobject]@{ Text = ""; Color = 'White' })
$lines.Add([pscustomobject]@{ Text = ("".PadRight($sepWidth,'-')); Color = 'DarkGray' })

foreach ($rec in $records) {
  $f = $rec -split "\x1f"
  if ($f.Length -lt 5) { continue }
  $sha = $f[0].Trim()
  $subj = $f[1].Trim()
  $author = $f[2].Trim()
  $rel = $f[3].Trim()
  $decoRaw = $f[4]
  $parents = if ($f.Length -ge 6) { $f[5].Trim() } else { "" }
  $isMerge = $false
  if ($parents -and ($parents -split '\s+').Count -gt 1) { $isMerge = $true }
  $deco = Simplify-Deco $decoRaw

  $lines.Add([pscustomobject]@{ Text = ("* {0}" -f $sha); Color = 'Cyan' })
  $lines.Add([pscustomobject]@{ Text = (" Author : {0}" -f $author); Color = 'Yellow' })
  $lines.Add([pscustomobject]@{ Text = (" When : {0}" -f $rel); Color = 'Green' })

  $wrapped = Wrap-Text $subj $wrapWidth
  if ($wrapped.Count -le 1) {
    $lines.Add([pscustomobject]@{ Text = (" Commit : {0}" -f $subj); Color = 'White' })
  } else {
    $lines.Add([pscustomobject]@{ Text = (" Commit : {0}" -f $wrapped[0]); Color = 'White' })
    foreach ($ln in $wrapped[1..($wrapped.Count-1)]) {
      $lines.Add([pscustomobject]@{ Text = (" {0}" -f $ln); Color = 'White' })
    }
  }

  if ($deco) { $lines.Add([pscustomobject]@{ Text = (" Refs : {0}" -f $deco); Color = 'Magenta' }) }
  if ($isMerge) { $lines.Add([pscustomobject]@{ Text = (" Merge : yes"); Color = 'Red' }) }

  $lines.Add([pscustomobject]@{ Text = ("".PadRight($sepWidth,'-')); Color = 'DarkGray' })
}

function Render([int]$offset) {
  Clear-Host
  try { $win = $Host.UI.RawUI.WindowSize } catch { $win = @{ Width = 100; Height = 30 } }
  $footer = "↑/↓ line · PgUp/PgDn page · Home/End · Q/Esc to quit"
  $visible = $win.Height - 2
  if ($visible -lt 5) { $visible = 5 }
  $end = [Math]::Min($offset + $visible, $lines.Count)
  for ($i = $offset; $i -lt $end; $i++) {
    $ln = $lines[$i]
    Write-Host $ln.Text -ForegroundColor $ln.Color
  }
  Write-Host ("".PadRight($win.Width,' ')) -NoNewline
  $Host.UI.RawUI.CursorPosition = @{X=0;Y=($win.Height-1)}
  Write-Host $footer -ForegroundColor DarkGray
}

$offset = 0
Render -offset $offset

$quit = $false
while (-not $quit) {
  $key = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
  $vk  = $key.VirtualKeyCode

  switch ($vk) {
    38 { if ($offset -gt 0) { $offset-- } ; Render -offset $offset }                                 # Up
    40 { if ($offset -lt [Math]::Max(0, $lines.Count-1)) { $offset++ } ; Render -offset $offset }    # Down
    33 { $jump = [Math]::Max(1, ($Host.UI.RawUI.WindowSize.Height - 4))
         $offset = [Math]::Max(0, $offset - $jump) ; Render -offset $offset }                        # PageUp
    34 { $jump = [Math]::Max(1, ($Host.UI.RawUI.WindowSize.Height - 4))
         $offset = [Math]::Min([Math]::Max(0,$lines.Count-1), $offset + $jump) ; Render -offset $offset } # PageDown
    36 { $offset = 0 ; Render -offset $offset }                                                      # Home
    35 { $offset = [Math]::Max(0, $lines.Count-1) ; Render -offset $offset }                         # End

    27 { $quit = $true }                                                                             # Esc
    13 { $quit = $true }                                                                             # Enter
    81 { $quit = $true }                                                                             # 'Q' key (VirtualKeyCode)
    default {
      # Some hosts only set Character for letters; support q/Q here too
      if ($key.Character -eq 'q' -or $key.Character -eq 'Q') {
        $quit = $true
      }
    }
  }
}

Clear-Host