Public/Search-SvnLog.ps1
|
function Search-SvnLog { <# .SYNOPSIS Searches SVN log history for one or more patterns in commit messages. .DESCRIPTION Queries svn log through repository HEAD without updating the working copy, parses the structured XML output, and displays full details for every commit whose message matches any of the given patterns. An informational message is shown when the working copy is behind the repository. .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). Default: 5. .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). .PARAMETER ShowPaths Includes changed paths in the output. This is the default; retained for compatibility. .PARAMETER HidePaths Omits changed paths and their labels from the output. Takes precedence over -ShowPaths. .PARAMETER Clipboard Copies the plain-text rendering of the results to the clipboard while still displaying the colored results on screen. .EXAMPLE Search-SvnLog.ps1 -Pattern "fix null reference" .EXAMPLE Search-SvnLog.ps1 -Pattern "bug fix" -IncludeFile "SettingsService\.cs" .EXAMPLE Search-SvnLog.ps1 C:\MyRepo "deploy(ment|ed)" -Limit 1000 .EXAMPLE Search-SvnLog.ps1 -Pattern "^JIRA-\d+" -CaseSensitive .EXAMPLE Search-SvnLog.ps1 -Pattern "bug 1234","bug 5678" -SimpleMatch .EXAMPLE Search-SvnLog.ps1 -Pattern "deploy" -Clipboard #> [CmdletBinding()] param( [Parameter(Position = 0)] [string] $Path = (Get-Location).Path, [Parameter(Position = 1)] [string[]] $Pattern, [Parameter()] [ValidateRange(0, [int]::MaxValue)] [int] $Limit = 5, [Parameter()] [switch] $CaseSensitive, [Parameter()] [switch] $SimpleMatch, [Parameter()] [string] $IncludeFile, [Parameter()] [switch] $Descending, [Parameter()] [switch] $ShowPaths = $true, [Parameter()] [switch] $HidePaths, [Parameter()] [ValidateRange(1, [int]::MaxValue)] [int] $First, [Parameter()] [ValidateRange(1, [int]::MaxValue)] [int] $Last, [Parameter()] [switch] $Clipboard, [Parameter()] [ValidateRange(0, [int]::MaxValue)] [int] $PageSize = 0 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $showPaths = $ShowPaths -and -not $HidePaths # --- Color palette for patterns ------------------------------------------------ $colorPalette = @( @{ Fg = 'White'; Bg = 'DarkBlue' } @{ Fg = 'White'; Bg = 'DarkGreen' } @{ Fg = 'White'; Bg = 'DarkMagenta' } @{ Fg = 'Black'; Bg = 'DarkYellow' } @{ Fg = 'White'; Bg = 'DarkRed' } @{ Fg = 'White'; Bg = 'DarkCyan' } @{ Fg = 'Black'; Bg = 'Gray' } ) # --- 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 } } } # --- Check whether the working copy is behind the repository ------------------ $statusArgs = @('status', '--show-updates', '--xml', $Path) $statusOutput = svn @statusArgs 2>&1 $workingCopyIsOutOfDate = $false if ($LASTEXITCODE -eq 0) { [xml]$statusXml = $statusOutput | Out-String $workingCopyIsOutOfDate = $statusXml.SelectNodes('/status/target/entry/repos-status').Count -gt 0 } if ($workingCopyIsOutOfDate) { $message = "Working copy '$Path' is out of date. The log is fetched through repository HEAD; the working copy was not updated." Write-Warning $message } # --- Build svn arguments ------------------------------------------------------- $svnArgs = @('log', '--xml', '--verbose', '--revision', 'HEAD:1', $Path) if ($Limit -gt 0) { $svnArgs += '--limit', $Limit } # --- 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.SelectNodes('/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 } # --- 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 # Build the plain-text lines for clipboard output. function Format-PlainSvnEntry { param($Entry, [int]$PatternIndex) $header = "r$($Entry.revision) | $(if ($Entry.author) { $Entry.author } else { '(none)' })" if ($Pattern -and $Pattern.Count -gt 1) { $header += " | [$($PatternIndex + 1)] $($Pattern[$PatternIndex])" } if ($Entry.date) { $d = [datetime]::Parse($Entry.date, $null, [System.Globalization.DateTimeStyles]::RoundtripKind).ToLocalTime() $header += " | $d" } $header $msg = ($(if ($Entry.msg) { $Entry.msg } else { '' }) -replace '\r', '').TrimEnd() if ($showPaths) { 'Message:' } foreach ($line in ($msg -split '\n')) { " $line" } if ($showPaths) { $paths = if ($Entry.paths) { $Entry.paths.path } else { $null } if ($paths) { 'Paths:' foreach ($p in $paths) { $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 { '' } " [$($p.action)]$nodeKind $($p.'#text')$copyFrom" } } } } function Write-ColorSvnEntry { param($Entry, [int]$PatternIndex) $color = $colorPalette[$PatternIndex % $colorPalette.Count] $author = if ($Entry.author) { $Entry.author } else { '(none)' } Write-Host 'r' -NoNewline -ForegroundColor Yellow Write-Host $Entry.revision -NoNewline -ForegroundColor $color.Fg -BackgroundColor $color.Bg Write-Host " | $author" -NoNewline if ($Pattern -and $Pattern.Count -gt 1) { Write-Host ' | ' -NoNewline Write-Host "[$($PatternIndex + 1)] $($Pattern[$PatternIndex])" -NoNewline -ForegroundColor $color.Fg -BackgroundColor $color.Bg } if ($Entry.date) { $date = [datetime]::Parse($Entry.date, $null, [System.Globalization.DateTimeStyles]::RoundtripKind).ToLocalTime() Write-Host " | $date" -NoNewline } Write-Host '' $message = ($(if ($Entry.msg) { $Entry.msg } else { '' }) -replace '\r', '').TrimEnd() if ($showPaths) { Write-Host 'Message:' -ForegroundColor Yellow } foreach ($line in ($message -split '\n')) { if ($Pattern) { Write-Host " $line" -ForegroundColor $color.Fg -BackgroundColor $color.Bg } else { Write-Host " $line" } } if ($showPaths) { $paths = if ($Entry.paths) { $Entry.paths.path } else { $null } if ($paths) { Write-Host 'Paths:' -ForegroundColor Yellow foreach ($path in $paths) { $action = $path.action $copyFromPath = $path.GetAttribute('copyfrom-path') $copyFromRevision = $path.GetAttribute('copyfrom-rev') $copyFrom = if ($copyFromPath) { " [copied from $copyFromPath@$copyFromRevision]" } else { '' } $nodeKindValue = $path.GetAttribute('node-kind') $nodeKind = if ($nodeKindValue) { " ($nodeKindValue)" } else { '' } $pathText = $path.'#text' $prefix = " [$action]$nodeKind " $pathColor = switch ($action) { 'A' { 'Green' } 'D' { 'Red' } 'M' { 'Cyan' } 'R' { 'Magenta' } default { 'White' } } if ($fileRegex -and $fileRegex.IsMatch($pathText)) { $match = $fileRegex.Match($pathText) $before = $pathText.Substring(0, $match.Index) $after = $pathText.Substring($match.Index + $match.Length) Write-Host "$prefix$before" -NoNewline -ForegroundColor $pathColor Write-Host $match.Value -NoNewline -ForegroundColor $color.Fg -BackgroundColor $color.Bg Write-Host "$after$copyFrom" -ForegroundColor $pathColor } else { Write-Host "$prefix$pathText$copyFrom" -ForegroundColor $pathColor } } } } } foreach ($result in $results) { if ($displayCount -gt 0) { Write-Host ('-' * 60) -ForegroundColor DarkGray } Write-ColorSvnEntry $result.Entry $result.PatternIndex $displayCount++ 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 } } } if ($Clipboard) { if (-not (Get-Command Set-Clipboard -ErrorAction SilentlyContinue)) { Write-Warning "Set-Clipboard is not available on this platform; skipping clipboard copy." } else { $clipLines = [System.Collections.Generic.List[string]]::new() for ($i = 0; $i -lt $results.Count; $i++) { if ($i -gt 0) { $clipLines.Add('-' * 60) } foreach ($l in (Format-PlainSvnEntry $results[$i].Entry $results[$i].PatternIndex)) { $clipLines.Add($l) } } ($clipLines -join [Environment]::NewLine) | Set-Clipboard Write-Host "Copied $($results.Count) revision(s) as plain text to the clipboard." -ForegroundColor Green } } } |