Public/Search-SvnLog.ps1

function Search-SvnLog {
<#
.SYNOPSIS
    Searches SVN log history for one or more patterns in commit messages.
 
.DESCRIPTION
    Runs svn log from the current (or specified) working directory, parses the
    structured XML output, and displays full details for every commit whose
    message matches any of the given patterns. Each pattern's matches are
    displayed in a distinct color.
 
.PARAMETER Pattern
    One or more patterns to match against SVN commit messages.
    By default these are regular expressions (case-insensitive).
    Use -SimpleMatch to treat them as literal strings.
 
.PARAMETER Path
    Working-copy path to run svn log against. Defaults to the current directory.
 
.PARAMETER Limit
    Maximum number of log entries to retrieve from SVN. 0 means no limit (all history).
 
.PARAMETER CaseSensitive
    When specified, the search is case-sensitive.
 
.PARAMETER SimpleMatch
    When specified, treats Pattern values as literal strings instead of regular expressions.
 
.PARAMETER IncludeFile
    A regex pattern to match against the changed file paths in each commit.
    Only commits containing at least one changed path matching this pattern are shown.
    Respects -SimpleMatch and -CaseSensitive.
 
.PARAMETER Descending
    When specified, displays results from newest to oldest.
    Default order is oldest to newest (newest last).
 
.PARAMETER First
    Display only the first N matching revisions (oldest).
 
.PARAMETER Last
    Display only the last N matching revisions (newest).
 
.EXAMPLE
    .\Search-SvnLog.ps1 -Pattern "fix null reference"
 
.EXAMPLE
    .\Search-SvnLog.ps1 -Pattern "bug fix" -IncludeFile "SettingsService\.cs"
 
.EXAMPLE
    .\Search-SvnLog.ps1 "deploy(ment|ed)" -Path C:\MyRepo -Limit 1000
 
.EXAMPLE
    .\Search-SvnLog.ps1 "^JIRA-\d+" -CaseSensitive
 
.EXAMPLE
    .\Search-SvnLog.ps1 -Pattern "bug 1234","bug 5678" -SimpleMatch
#>

[CmdletBinding()]
param(
    [Parameter(Position = 0)]
    [string[]] $Pattern,

    [Parameter(Position = 1)]
    [string] $Path = (Get-Location).Path,

    [Parameter()]
    [ValidateRange(0, [int]::MaxValue)]
    [int] $Limit = 200,

    [Parameter()]
    [switch] $CaseSensitive,

    [Parameter()]
    [switch] $SimpleMatch,

    [Parameter()]
    [string] $IncludeFile,

    [Parameter()]
    [switch] $Descending,

    [Parameter()]
    [switch] $ShowPaths,

    [Parameter()]
    [ValidateRange(1, [int]::MaxValue)]
    [int] $First,

    [Parameter()]
    [ValidateRange(1, [int]::MaxValue)]
    [int] $Last,

    [Parameter()]
    [ValidateRange(0, [int]::MaxValue)]
    [int] $PageSize = 0
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# --- Color palette for patterns ------------------------------------------------
$colorPalette = @(
    @{ Fg = 'White';  Bg = 'DarkBlue';    Spectre = 'blue'    }
    @{ Fg = 'White';  Bg = 'DarkGreen';   Spectre = 'green'   }
    @{ Fg = 'White';  Bg = 'DarkMagenta'; Spectre = 'purple'  }
    @{ Fg = 'Black';  Bg = 'DarkYellow';  Spectre = 'yellow'  }
    @{ Fg = 'White';  Bg = 'DarkRed';     Spectre = 'red'     }
    @{ Fg = 'White';  Bg = 'DarkCyan';    Spectre = 'teal'    }
    @{ Fg = 'Black';  Bg = 'Gray';        Spectre = 'grey'    }
)

# --- Check for PwshSpectreConsole module ---------------------------------------
$hasSpectre = $null -ne (Get-Module -ListAvailable -Name PwshSpectreConsole)
if (-not $hasSpectre) {
    Write-Host "PwshSpectreConsole module not found. Install it for enhanced output?" -ForegroundColor Yellow
    Write-Host " [Y] Yes [N] No (continue without it)" -ForegroundColor DarkGray -NoNewline
    $keyInfo = [System.Console]::ReadKey($true)
    Write-Host ''
    if ($keyInfo.Key -eq [System.ConsoleKey]::Y) {
        Write-Host "Installing PwshSpectreConsole..." -ForegroundColor Cyan
        Install-Module PwshSpectreConsole -Scope CurrentUser -Force -AllowClobber
        $hasSpectre = $true
    }
}
if ($hasSpectre) {
    Import-Module PwshSpectreConsole -ErrorAction SilentlyContinue
    $hasSpectre = $null -ne (Get-Command Format-SpectrePanel -ErrorAction SilentlyContinue)
}

# --- Build regex list for each pattern -----------------------------------------
$regexOptions = if ($CaseSensitive) {
    [System.Text.RegularExpressions.RegexOptions]::None
} else {
    [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
}

$regexList = [System.Collections.Generic.List[System.Text.RegularExpressions.Regex]]::new()
if ($Pattern) {
    foreach ($pat in $Pattern) {
        $escaped = if ($SimpleMatch) { [System.Text.RegularExpressions.Regex]::Escape($pat) } else { $pat }
        try {
            $regexList.Add([System.Text.RegularExpressions.Regex]::new($escaped, $regexOptions))
        } catch [System.ArgumentException] {
            Write-Error "Invalid regular expression '$pat': $($_.Exception.Message)"
            return
        }
    }
}

# --- Build svn arguments -------------------------------------------------------
$svnArgs = @('log', '--xml', '--verbose', $Path)
if ($Limit -gt 0) {
    $svnArgs += '--limit', $Limit
}

Write-Host "Fetching SVN log for: $Path" -ForegroundColor Cyan
if ($Limit -gt 0) {
    Write-Host " (limited to last $Limit entries)" -ForegroundColor DarkGray
} else {
    Write-Host " (no limit — retrieving all entries)" -ForegroundColor DarkGray
}
Write-Host ''
if ($Pattern) {
    Write-Host "Patterns:" -ForegroundColor Cyan
    for ($i = 0; $i -lt $Pattern.Count; $i++) {
        $c = $colorPalette[$i % $colorPalette.Count]
        Write-Host " [$($i + 1)] " -NoNewline -ForegroundColor Cyan
        Write-Host $Pattern[$i] -NoNewline -ForegroundColor $c.Fg -BackgroundColor $c.Bg
        Write-Host ''
    }
}
if ($IncludeFile) {
    Write-Host "File filter: $IncludeFile" -ForegroundColor Cyan
}
Write-Host ''

# --- Retrieve and parse log ----------------------------------------------------
$rawXml = svn @svnArgs 2>&1

if ($LASTEXITCODE -ne 0) {
    $errorText = $rawXml | Where-Object { $_ -is [string] } | Out-String
    Write-Error "svn log failed (exit $LASTEXITCODE):`n$errorText"
    return
}

[xml]$logXml = $rawXml | Out-String

$entries = $logXml.log.logentry
if (-not $entries) {
    Write-Host "No log entries found." -ForegroundColor Yellow
    return
}

# --- Build file filter regex if specified --------------------------------------
$fileRegex = $null
if ($IncludeFile) {
    $escapedFile = if ($SimpleMatch) { [System.Text.RegularExpressions.Regex]::Escape($IncludeFile) } else { $IncludeFile }
    try {
        $fileRegex = [System.Text.RegularExpressions.Regex]::new($escapedFile, $regexOptions)
    } catch [System.ArgumentException] {
        Write-Error "Invalid IncludeFile pattern '$IncludeFile': $($_.Exception.Message)"
        return
    }
}

# --- Filter and group matching entries by first matching pattern ---------------
$results = [System.Collections.Generic.List[pscustomobject]]::new()

foreach ($entry in $entries) {
    if ($regexList.Count -gt 0 -and -not $entry.msg) { continue }

    # Apply file filter if specified
    if ($fileRegex) {
        $paths = if ($entry.paths) { $entry.paths.path } else { $null }
        if (-not $paths) { continue }
        $fileMatch = $false
        foreach ($p in $paths) {
            if ($fileRegex.IsMatch($p.'#text')) {
                $fileMatch = $true
                break
            }
        }
        if (-not $fileMatch) { continue }
    }

    if ($regexList.Count -gt 0) {
        for ($i = 0; $i -lt $regexList.Count; $i++) {
            if ($regexList[$i].IsMatch($entry.msg)) {
                $results.Add([pscustomobject]@{ Entry = $entry; PatternIndex = $i })
                break  # first matching pattern wins the color
            }
        }
    } else {
        # No message pattern — include all entries that passed the file filter
        $results.Add([pscustomobject]@{ Entry = $entry; PatternIndex = 0 })
    }
}

if ($results.Count -eq 0) {
    Write-Host "No commits found matching the specified criteria." -ForegroundColor Yellow
    return
}

Write-Host "Found $($results.Count) matching commit(s)" -ForegroundColor Green
Write-Host ''

# --- Sort results (default: oldest first) --------------------------------------
if (-not $Descending) {
    $results.Reverse()
}

# --- Apply -First / -Last slicing ----------------------------------------------
if ($First -and $Last) {
    Write-Error "Cannot specify both -First and -Last."
    return
}
if ($First) {
    if ($First -lt $results.Count) {
        $results = [System.Collections.Generic.List[pscustomobject]]($results.GetRange(0, $First))
    }
}
if ($Last) {
    if ($Last -lt $results.Count) {
        $results = [System.Collections.Generic.List[pscustomobject]]($results.GetRange($results.Count - $Last, $Last))
    }
}

# --- Display results -----------------------------------------------------------
$displayCount = 0

foreach ($result in $results) {
    $entry = $result.Entry
    $color = $colorPalette[$result.PatternIndex % $colorPalette.Count]

    # Core fields — single line: Revision, Author, Pattern, Date
    Write-Host "r" -NoNewline -ForegroundColor Yellow
    Write-Host $entry.revision -NoNewline -ForegroundColor $color.Fg -BackgroundColor $color.Bg
    Write-Host " | " -NoNewline -ForegroundColor DarkGray
    Write-Host $(if ($entry.author) { $entry.author } else { '(none)' }) -NoNewline
    if ($Pattern -and $Pattern.Count -gt 1) {
        Write-Host " | " -NoNewline -ForegroundColor DarkGray
        Write-Host "[$($result.PatternIndex + 1)] $($Pattern[$result.PatternIndex])" -NoNewline -ForegroundColor $color.Fg -BackgroundColor $color.Bg
    }
    $rawDate = $entry.date
    if ($rawDate) {
        $parsedDate = [datetime]::Parse($rawDate, $null, [System.Globalization.DateTimeStyles]::RoundtripKind)
        $localDate  = $parsedDate.ToLocalTime()
        $timeSpan   = [datetime]::Now - $localDate
        $ago = if ($timeSpan.TotalDays -ge 365) {
            $years = [math]::Floor($timeSpan.TotalDays / 365)
            "$years year$(if ($years -ne 1) {'s'}) ago"
        } elseif ($timeSpan.TotalDays -ge 30) {
            $months = [math]::Floor($timeSpan.TotalDays / 30)
            "$months month$(if ($months -ne 1) {'s'}) ago"
        } elseif ($timeSpan.TotalDays -ge 7) {
            $weeks = [math]::Floor($timeSpan.TotalDays / 7)
            "$weeks week$(if ($weeks -ne 1) {'s'}) ago"
        } elseif ($timeSpan.TotalDays -ge 1) {
            $days = [math]::Floor($timeSpan.TotalDays)
            "$days day$(if ($days -ne 1) {'s'}) ago"
        } elseif ($timeSpan.TotalHours -ge 1) {
            $hours = [math]::Floor($timeSpan.TotalHours)
            "$hours hour$(if ($hours -ne 1) {'s'}) ago"
        } else {
            $mins = [math]::Floor($timeSpan.TotalMinutes)
            "$mins minute$(if ($mins -ne 1) {'s'}) ago"
        }
        Write-Host " | " -NoNewline -ForegroundColor DarkGray
        Write-Host $localDate -NoNewline
        Write-Host " ($ago)" -NoNewline -ForegroundColor DarkGray
    }
    Write-Host ''

    # Changed paths
    if ($ShowPaths) {
    $paths = if ($entry.paths) { $entry.paths.path } else { $null }
    if ($paths) {
        if ($hasSpectre) {
            $pathLines = foreach ($p in $paths) {
                $action    = $p.action
                $cfPath    = $p.GetAttribute('copyfrom-path')
                $cfRev     = $p.GetAttribute('copyfrom-rev')
                $copyFrom  = if ($cfPath) { " [copied from $(Get-SpectreEscapedText $cfPath)@$cfRev]" } else { '' }
                $nk        = $p.GetAttribute('node-kind')
                $nodeKind  = if ($nk) { " ($nk)" } else { '' }
                $pathText  = Get-SpectreEscapedText $p.'#text'
                $spectrePathColor = switch ($action) {
                    'A' { 'green'   }
                    'D' { 'red'     }
                    'M' { 'cyan1'   }
                    'R' { 'purple'  }
                    default { 'white' }
                }
                "[$spectrePathColor]$(Get-SpectreEscapedText "[$action]")$nodeKind $pathText$copyFrom[/]"
            }
            $pathContent = $pathLines -join "`n"
            $prevRendering = $PSStyle.OutputRendering
            $PSStyle.OutputRendering = 'Ansi'
            $panel = ($pathContent | Format-SpectrePanel -Header "Paths" -Color $color.Spectre -Expand | Out-String).Trim()
            $PSStyle.OutputRendering = $prevRendering
            [System.Console]::WriteLine($panel)
        } else {
            Write-Host "Paths :" -ForegroundColor Yellow
            foreach ($p in $paths) {
                $action    = $p.action
                $cfPath    = $p.GetAttribute('copyfrom-path')
                $cfRev     = $p.GetAttribute('copyfrom-rev')
                $copyFrom  = if ($cfPath) { " [copied from $cfPath@$cfRev]" } else { '' }
                $nk        = $p.GetAttribute('node-kind')
                $nodeKind  = if ($nk) { " ($nk)" } else { '' }
                $pathText  = $p.'#text'
                $prefix    = " [$action]$nodeKind "

                if ($fileRegex -and $fileRegex.IsMatch($pathText)) {
                    # Highlight the matching portion of the path
                    $m = $fileRegex.Match($pathText)
                    $before = $pathText.Substring(0, $m.Index)
                    $match  = $m.Value
                    $after  = $pathText.Substring($m.Index + $m.Length)
                    Write-Host $prefix -NoNewline -ForegroundColor Cyan
                    Write-Host $before -NoNewline -ForegroundColor Cyan
                    Write-Host $match -NoNewline -ForegroundColor $color.Fg -BackgroundColor $color.Bg
                    Write-Host "$after$copyFrom" -ForegroundColor Cyan
                } else {
                    $pathColor = switch ($action) {
                        'A' { 'Green'   }
                        'D' { 'Red'     }
                        'M' { 'Cyan'    }
                        'R' { 'Magenta' }
                        default { 'White' }
                    }
                    Write-Host "$prefix$pathText$copyFrom" -ForegroundColor $pathColor
                }
            }
        }
    }
    }

    # Commit message — colored by matching pattern only if -Pattern was specified
    $msg = $(if ($entry.msg) { $entry.msg } else { '' }) -replace '\r', ''
    if ($hasSpectre) {
        $spectreColor = $color.Spectre
        $escapedMsg = $msg | Get-SpectreEscapedText
        $prevRendering = $PSStyle.OutputRendering
        $PSStyle.OutputRendering = 'Ansi'
        $panel = ($escapedMsg | Format-SpectrePanel -Header "Message" -Color $spectreColor -Expand | Out-String).TrimStart()
        $PSStyle.OutputRendering = $prevRendering
        [System.Console]::Write($panel)
    } else {
        Write-Host "Message :" -ForegroundColor Yellow
        $lines = $msg -split '\n'
        foreach ($line in $lines) {
            if ($Pattern) {
                Write-Host " $line" -NoNewline -ForegroundColor $color.Fg -BackgroundColor $color.Bg
                Write-Host ''
            } else {
                Write-Host " $line"
            }
        }
    }

    $displayCount++

    # Pause after PageSize commits
    if ($PageSize -gt 0 -and $displayCount -lt $results.Count -and ($displayCount % $PageSize) -eq 0) {
        Write-Host "-- $displayCount of $($results.Count) -- Press any key for more, Q to quit --" -ForegroundColor DarkGray -NoNewline
        $keyInfo = [System.Console]::ReadKey($true)
        Write-Host ''
        if ($keyInfo.Key -eq [System.ConsoleKey]::Q) { break }
    }
}

Write-Host "Total matches: $($results.Count)" -ForegroundColor Green
Write-Host ''
}