#
.SYNOPSIS
Build utilities module for PowerShell scripts.
.DESCRIPTION
Provides reusable functions for build/release scripts:
- Step timing and progress tracking
- Prerequisite/command validation
- Git status utilities
- Console output helpers
.USAGE
Import-Module .\BuildUtils.psm1
Initialize-StepTimer
Start-Step "Building project"
# ... do work ...
Complete-Step "OK"
Show-TimingSummary
#>
# ==============================================================================
# MODULE STATE
# ==============================================================================
$script:StepTimerState = @{
TotalStopwatch = $null
CurrentStep = $null
StepTimings = @()
}
# ==============================================================================
# STEP TIMING FUNCTIONS
# ==============================================================================
function Initialize-StepTimer {
<#
.SYNOPSIS
Initialize the step timer. Call at the start of your script.
#>
$script:StepTimerState.TotalStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
$script:StepTimerState.StepTimings = @()
$script:StepTimerState.CurrentStep = $null
}
function Start-Step {
<#
.SYNOPSIS
Start timing a new step with console output.
.PARAMETER Name
Name/description of the step.
#>
param([Parameter(Mandatory)][string]$Name)
$script:StepTimerState.CurrentStep = @{
Name = $Name
Stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
}
Write-Host ""
Write-Host "[$([DateTime]::Now.ToString('HH:mm:ss'))] $Name..." -ForegroundColor Cyan
}
function Complete-Step {
<#
.SYNOPSIS
Complete the current step and record timing.
.PARAMETER Status
Status of the step: OK, SKIP, FAIL, WARN
#>
param([string]$Status = "OK")
$step = $script:StepTimerState.CurrentStep
if ($step) {
$step.Stopwatch.Stop()
$elapsed = $step.Stopwatch.Elapsed
$script:StepTimerState.StepTimings += @{
Name = $step.Name
Duration = $elapsed
Status = $Status
}
$timeStr = "{0:mm\:ss\.fff}" -f $elapsed
$color = switch ($Status) {
"OK" { "Green" }
"SKIP" { "Yellow" }
"WARN" { "Yellow" }
default { "Red" }
}
if ($Status -eq "SKIP") {
Write-Host " Skipped" -ForegroundColor $color
}
else {
$prefix = if ($Status -eq "OK") { "Completed" } else { "Failed" }
Write-Host " $prefix in $timeStr" -ForegroundColor $color
}
}
}
function Get-StepTimings {
<#
.SYNOPSIS
Get the recorded step timings.
.OUTPUTS
Array of step timing objects.
#>
return $script:StepTimerState.StepTimings
}
function Show-TimingSummary {
<#
.SYNOPSIS
Display a summary of all step timings.
#>
Write-Host ""
Write-Host "=========================================="
Write-Host "TIMING SUMMARY"
Write-Host "=========================================="
foreach ($step in $script:StepTimerState.StepTimings) {
$timeStr = "{0:mm\:ss\.fff}" -f $step.Duration
$status = $step.Status
$color = switch ($status) {
"OK" { "Green" }
"SKIP" { "Yellow" }
"WARN" { "Yellow" }
default { "Red" }
}
Write-Host (" [{0,-4}] {1,-40} {2}" -f $status, $step.Name, $timeStr) -ForegroundColor $color
}
if ($script:StepTimerState.TotalStopwatch) {
$script:StepTimerState.TotalStopwatch.Stop()
$totalTime = "{0:mm\:ss\.fff}" -f $script:StepTimerState.TotalStopwatch.Elapsed
Write-Host "----------------------------------------"
Write-Host " Total: $totalTime" -ForegroundColor Cyan
}
}
# ==============================================================================
# PREREQUISITE FUNCTIONS
# ==============================================================================
function Test-CommandExists {
<#
.SYNOPSIS
Check if a command exists.
.PARAMETER Command
Command name to check.
.OUTPUTS
Boolean indicating if command exists.
#>
param([Parameter(Mandatory)][string]$Command)
return [bool](Get-Command $Command -ErrorAction SilentlyContinue)
}
function Assert-Command {
<#
.SYNOPSIS
Assert that a command exists, exit if not.
.PARAMETER Command
Command name to check.
.PARAMETER ExitCode
Exit code to use if command is missing (default: 1).
#>
param(
[Parameter(Mandatory)][string]$Command,
[int]$ExitCode = 1
)
if (-not (Test-CommandExists $Command)) {
Write-Error "Required command '$Command' is not available. Aborting."
exit $ExitCode
}
}
function Assert-Commands {
<#
.SYNOPSIS
Assert that multiple commands exist.
.PARAMETER Commands
Array of command names to check.
#>
param([Parameter(Mandatory)][string[]]$Commands)
foreach ($cmd in $Commands) {
Assert-Command $cmd
}
}
function Test-EnvironmentVariable {
<#
.SYNOPSIS
Check if an environment variable is set.
.PARAMETER Name
Environment variable name.
.OUTPUTS
Boolean indicating if variable is set and not empty.
#>
param([Parameter(Mandatory)][string]$Name)
$value = [Environment]::GetEnvironmentVariable($Name)
return -not [string]::IsNullOrWhiteSpace($value)
}
function Assert-EnvironmentVariable {
<#
.SYNOPSIS
Assert that an environment variable is set, exit if not.
.PARAMETER Name
Environment variable name.
.PARAMETER ExitCode
Exit code to use if variable is missing (default: 1).
#>
param(
[Parameter(Mandatory)][string]$Name,
[int]$ExitCode = 1
)
if (-not (Test-EnvironmentVariable $Name)) {
Write-Error "Required environment variable '$Name' is not set. Aborting."
exit $ExitCode
}
}
# ==============================================================================
# GIT UTILITIES
# ==============================================================================
function Get-GitStatus {
<#
.SYNOPSIS
Get git status as structured object.
.OUTPUTS
Object with Staged, Modified, Untracked arrays and IsClean boolean.
#>
$status = @{
Staged = @()
Modified = @()
Untracked = @()
Deleted = @()
IsClean = $true
}
$statusLines = git status --porcelain 2>$null
if (-not $statusLines) { return $status }
$status.IsClean = $false
foreach ($line in ($statusLines -split "`n")) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
$index = $line.Substring(0, 1)
$workTree = $line.Substring(1, 1)
$file = $line.Substring(3)
# Staged changes
if ($index -match '[MADRC]') {
$status.Staged += $file
}
# Unstaged modifications
if ($workTree -eq 'M') {
$status.Modified += $file
}
# Deleted files
if ($index -eq 'D' -or $workTree -eq 'D') {
$status.Deleted += $file
}
# Untracked files
if ($index -eq '?' -and $workTree -eq '?') {
$status.Untracked += $file
}
}
return $status
}
function Show-GitStatus {
<#
.SYNOPSIS
Display git status in a formatted, colored output.
.PARAMETER Status
Git status object from Get-GitStatus (optional, will fetch if not provided).
#>
param([hashtable]$Status)
if (-not $Status) {
$Status = Get-GitStatus
}
if ($Status.IsClean) {
Write-Host " Working directory is clean" -ForegroundColor Green
return
}
if ($Status.Staged.Count -gt 0) {
Write-Host " Staged ($($Status.Staged.Count)):" -ForegroundColor Green
$Status.Staged | ForEach-Object { Write-Host " + $_" -ForegroundColor Green }
}
if ($Status.Modified.Count -gt 0) {
Write-Host " Modified ($($Status.Modified.Count)):" -ForegroundColor Yellow
$Status.Modified | ForEach-Object { Write-Host " M $_" -ForegroundColor Yellow }
}
if ($Status.Deleted.Count -gt 0) {
Write-Host " Deleted ($($Status.Deleted.Count)):" -ForegroundColor Red
$Status.Deleted | ForEach-Object { Write-Host " D $_" -ForegroundColor Red }
}
if ($Status.Untracked.Count -gt 0) {
Write-Host " Untracked ($($Status.Untracked.Count)):" -ForegroundColor Cyan
$Status.Untracked | ForEach-Object { Write-Host " ? $_" -ForegroundColor Cyan }
}
}
function Get-CurrentBranch {
<#
.SYNOPSIS
Get the current git branch name.
.OUTPUTS
Branch name string or $null if not in a git repo.
#>
try {
$branch = git rev-parse --abbrev-ref HEAD 2>$null
if ($LASTEXITCODE -eq 0) { return $branch }
} catch { }
return $null
}
function Get-LastTag {
<#
.SYNOPSIS
Get the most recent git tag.
.OUTPUTS
Tag name string or $null if no tags exist.
#>
try {
$tag = git describe --tags --abbrev=0 2>$null
if ($LASTEXITCODE -eq 0) { return $tag }
} catch { }
return $null
}
function Get-CommitsSinceTag {
<#
.SYNOPSIS
Get commits since a specific tag (or all commits if no tag).
.PARAMETER Tag
Tag to start from (optional, uses last tag if not specified).
.PARAMETER Format
Output format: oneline, full, hash (default: oneline).
.OUTPUTS
Array of commit strings.
#>
param(
[string]$Tag,
[ValidateSet("oneline", "full", "hash")]
[string]$Format = "oneline"
)
if (-not $Tag) {
$Tag = Get-LastTag
}
$formatArg = switch ($Format) {
"oneline" { "--oneline" }
"full" { "--format=full" }
"hash" { "--format=%H" }
}
try {
if ($Tag) {
$commits = git log "$Tag..HEAD" $formatArg --no-merges 2>$null
}
else {
$commits = git log -50 $formatArg --no-merges 2>$null
}
if ($commits) {
return $commits -split "`n" | Where-Object { $_.Trim() -ne "" }
}
} catch { }
return @()
}
function Get-VersionBumpCommit {
<#
.SYNOPSIS
Find the commit where a version string was introduced in a file.
.PARAMETER Version
Version string to search for (e.g., "1.6.1").
.PARAMETER FilePath
File path to search in (e.g., "*.csproj").
.OUTPUTS
Commit hash where version first appeared, or $null.
#>
param(
[Parameter(Mandatory)][string]$Version,
[string]$FilePath = "*.csproj"
)
try {
# Find commit that introduced this version string
$commit = git log -S "$Version" --format="%H" --reverse -- $FilePath 2>$null | Select-Object -First 1
if ($commit) { return $commit.Trim() }
# Try alternative format (without tags)
$commit = git log -S "$Version" --format="%H" --reverse -- $FilePath 2>$null | Select-Object -First 1
if ($commit) { return $commit.Trim() }
} catch { }
return $null
}
function Get-CommitsForVersion {
<#
.SYNOPSIS
Get commits for a specific version (from previous version to HEAD).
Designed for pre-commit workflow: version is bumped locally but not yet committed.
.PARAMETER Version
Version string (e.g., "1.6.1") - the NEW version being prepared.
.PARAMETER CsprojPath
Path to csproj file (absolute or relative).
.PARAMETER Format
Output format: oneline, full, hash, detailed (default: oneline).
"detailed" includes commit message + changed files.
.OUTPUTS
Array of commit strings for this version.
#>
param(
[Parameter(Mandatory)][string]$Version,
[string]$CsprojPath = "*.csproj",
[ValidateSet("oneline", "full", "hash", "detailed")]
[string]$Format = "oneline"
)
# Get git repo root for path conversion
$gitRoot = (git rev-parse --show-toplevel 2>$null)
if ($gitRoot) { $gitRoot = $gitRoot.Trim() }
# Get path relative to git root using git itself (handles drive letter issues)
function ConvertTo-GitPath {
param([string]$Path)
if (-not $Path) { return $null }
# If it's a relative path, use it directly
if (-not [System.IO.Path]::IsPathRooted($Path)) {
# Get path relative to repo root by combining with current dir offset
$cwdRelative = git rev-parse --show-prefix 2>$null
if ($cwdRelative) {
$cwdRelative = $cwdRelative.Trim().TrimEnd('/')
if ($cwdRelative) {
return "$cwdRelative/$($Path -replace '\\', '/')"
}
}
return $Path -replace '\\', '/'
}
# For absolute paths, try to make relative using git
Push-Location (Split-Path $Path -Parent) -ErrorAction SilentlyContinue
try {
$prefix = git rev-parse --show-prefix 2>$null
if ($prefix) {
$prefix = $prefix.Trim().TrimEnd('/')
$filename = Split-Path $Path -Leaf
if ($prefix) {
return "$prefix/$filename"
}
return $filename
}
}
finally {
Pop-Location -ErrorAction SilentlyContinue
}
# Fallback: normalize to forward slashes
return $Path -replace '\\', '/'
}
# Find actual csproj file if glob pattern
$actualCsprojPath = $CsprojPath
if ($CsprojPath -match '\*') {
$found = Get-ChildItem -Path $CsprojPath -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '\.csproj$' } |
Select-Object -First 1
if ($found) { $actualCsprojPath = $found.FullName }
}
$gitCsprojPath = ConvertTo-GitPath $actualCsprojPath
# Determine commit range
$range = ""
try {
# Check if this version is already committed
$versionCommit = Get-VersionBumpCommit -Version $Version -FilePath $CsprojPath
if ($versionCommit) {
# Version already in git history - get commits from that point
$range = "$versionCommit^..HEAD"
}
else {
# Version NOT committed yet (normal pre-commit workflow)
# Find the PREVIOUS version from the committed csproj
if ($gitCsprojPath) {
$committedContent = git show "HEAD:$gitCsprojPath" 2>$null
if ($committedContent) {
$prevVersionMatch = [regex]::Match(($committedContent -join "`n"), '([^<]+)')
if ($prevVersionMatch.Success) {
$prevVersion = $prevVersionMatch.Groups[1].Value
# Find when previous version was introduced
$prevCommit = Get-VersionBumpCommit -Version $prevVersion -FilePath $CsprojPath
if ($prevCommit) {
# Get commits AFTER previous version was set (these are unreleased)
$range = "$prevCommit..HEAD"
}
}
}
}
# Fallback to last tag if still no range
if (-not $range) {
$lastTag = Get-LastTag
if ($lastTag) {
$range = "$lastTag..HEAD"
}
}
}
} catch { }
# For detailed format, get commit + files changed
if ($Format -eq "detailed") {
return Get-DetailedCommits -Range $range
}
$formatArg = switch ($Format) {
"oneline" { "--oneline" }
"full" { "--format=full" }
"hash" { "--format=%H" }
}
try {
if ($range) {
$commits = git log $range $formatArg --no-merges 2>$null
}
else {
$commits = git log -30 $formatArg --no-merges 2>$null
}
if ($commits) {
return $commits -split "`n" | Where-Object { $_.Trim() -ne "" }
}
} catch { }
return @()
}
function Get-DetailedCommits {
<#
.SYNOPSIS
Get detailed commit info including changed files.
.PARAMETER Range
Git commit range (e.g., "v1.0.0..HEAD").
.PARAMETER MaxCommits
Maximum commits to return (default: 50).
.OUTPUTS
Array of formatted strings: "hash message [files: file1, file2, ...]"
#>
param(
[string]$Range,
[int]$MaxCommits = 50
)
$results = @()
try {
# Get commit hashes
if ($Range) {
$hashes = git log $Range --format="%H" --no-merges -n $MaxCommits 2>$null
}
else {
$hashes = git log --format="%H" --no-merges -n $MaxCommits 2>$null
}
if (-not $hashes) { return @() }
$hashArray = $hashes -split "`n" | Where-Object { $_.Trim() -ne "" }
foreach ($hash in $hashArray) {
$hash = $hash.Trim()
if (-not $hash) { continue }
# Get commit message (first line)
$message = git log -1 --format="%s" $hash 2>$null
if (-not $message) { continue }
# Get changed files (source files only)
$files = git diff-tree --no-commit-id --name-only -r $hash 2>$null
$sourceFiles = @()
if ($files) {
$sourceFiles = ($files -split "`n" | Where-Object {
$_.Trim() -ne "" -and ($_ -match '\.(cs|fs|vb|ts|js|py|java|go|rs|cpp|c|h)$')
}) | Select-Object -First 5 # Limit to 5 files per commit
}
# Format output
$shortHash = $hash.Substring(0, 7)
if ($sourceFiles.Count -gt 0) {
$fileList = $sourceFiles -join ", "
$results += "$shortHash $message [files: $fileList]"
}
else {
$results += "$shortHash $message"
}
}
} catch { }
return $results
}
function Get-UncommittedChanges {
<#
.SYNOPSIS
Get summary of uncommitted changes (staged, unstaged, untracked) with meaningful descriptions.
.PARAMETER IncludeContent
If true, includes file content diffs (for AI analysis).
.PARAMETER FileFilter
File extension filter (default: .cs for C# files).
.OUTPUTS
Object with Staged, Modified, Untracked arrays and Summary with change descriptions.
#>
param(
[switch]$IncludeContent,
[string]$FileFilter = ".cs"
)
$result = @{
Staged = @()
Modified = @()
Untracked = @()
Deleted = @()
Summary = @()
}
try {
# Get current directory prefix relative to repo root
$cwdPrefix = git rev-parse --show-prefix 2>$null
if ($cwdPrefix) { $cwdPrefix = $cwdPrefix.Trim().TrimEnd('/') }
# Get all changes using git status porcelain
$status = git status --porcelain 2>$null
if (-not $status) { return $result }
$statusLines = $status -split "`n" | Where-Object { $_.Trim() -ne "" }
foreach ($line in $statusLines) {
if ($line.Length -lt 3) { continue }
$statusCode = $line.Substring(0, 2)
$filePath = $line.Substring(3).Trim().Trim('"')
# Filter by extension
if ($FileFilter -and -not $filePath.EndsWith($FileFilter)) { continue }
# Convert repo-relative path to cwd-relative path for git diff
$diffPath = $filePath
if ($cwdPrefix -and $filePath.StartsWith("$cwdPrefix/")) {
$diffPath = $filePath.Substring($cwdPrefix.Length + 1)
}
# Categorize by status (store both paths)
$pathInfo = @{ Full = $filePath; Diff = $diffPath }
if ($statusCode -match '^\?\?') {
$result.Untracked += $pathInfo
} elseif ($statusCode -match '^D' -or $statusCode -match '^.D') {
# Deleted files (staged or unstaged)
$result.Deleted += $pathInfo
} elseif ($statusCode -match '^[MARC]') {
$result.Staged += $pathInfo
} elseif ($statusCode -match '^.[MARC]') {
$result.Modified += $pathInfo
}
}
# Process modified/staged files (get diff)
$allModified = @($result.Staged) + @($result.Modified)
foreach ($fileInfo in $allModified) {
if (-not $fileInfo) { continue }
$diffPath = $fileInfo.Diff
$fullPath = $fileInfo.Full
$diff = git diff -- $diffPath 2>$null
if (-not $diff) { $diff = git diff --cached -- $diffPath 2>$null }
$fileName = Split-Path $fullPath -Leaf
$className = $fileName -replace '\.cs$', ''
if ($diff) {
$changes = @()
$diffLines = $diff -split "`n"
foreach ($line in $diffLines) {
# Added method/class/property
if ($line -match '^\+\s*(public|private|protected|internal)\s+static\s+\w+\s+(\w+)\s*\(') {
$changes += "Added static method $($Matches[2])"
}
elseif ($line -match '^\+\s*(public|private|protected|internal)\s+\w+\s+(\w+)\s*\([^)]*\)\s*\{?') {
$methodName = $Matches[2]
if ($methodName -notmatch '^(get|set|if|for|while|switch|new|return)$') {
$changes += "Added method $methodName"
}
}
elseif ($line -match '^\+\s*(public|private|protected|internal)\s+(class|interface|struct|enum)\s+(\w+)') {
$changes += "Added $($Matches[2]) $($Matches[3])"
}
# Detect try/catch blocks, error handling
elseif ($line -match '^\+.*catch\s*\(') {
$changes += "Added exception handling"
}
}
$changes = $changes | Select-Object -Unique | Select-Object -First 4
if ($changes.Count -gt 0) {
$result.Summary += "(uncommitted) $className`: $($changes -join ', ')"
}
else {
# Fallback to line count
$addCount = ($diffLines | Where-Object { $_ -match '^\+[^+]' }).Count
$delCount = ($diffLines | Where-Object { $_ -match '^-[^-]' }).Count
$result.Summary += "(uncommitted) $className`: Modified (+$addCount/-$delCount lines)"
}
}
else {
$result.Summary += "(uncommitted) $className`: Modified"
}
}
# Process untracked files (new files)
foreach ($fileInfo in $result.Untracked) {
if (-not $fileInfo) { continue }
$diffPath = $fileInfo.Diff
$fullPath = $fileInfo.Full
$fileName = Split-Path $fullPath -Leaf
$className = $fileName -replace '\.cs$', ''
# Read file to understand what it contains
$content = Get-Content $diffPath -Raw -ErrorAction SilentlyContinue
if ($content) {
$features = @()
if ($content -match 'class\s+(\w+)') { $features += "class $($Matches[1])" }
if ($content -match 'interface\s+(\w+)') { $features += "interface $($Matches[1])" }
if ($content -match 'enum\s+(\w+)') { $features += "enum $($Matches[1])" }
if ($content -match '\[Fact\]|\[Theory\]') { $features += "unit tests" }
if ($features.Count -gt 0) {
$result.Summary += "(new file) $className`: Added $($features -join ', ')"
}
else {
$result.Summary += "(new file) $className`: New file"
}
}
else {
$result.Summary += "(new file) $className`: New file"
}
}
# Process deleted files
foreach ($fileInfo in $result.Deleted) {
if (-not $fileInfo) { continue }
$fullPath = $fileInfo.Full
$fileName = Split-Path $fullPath -Leaf
$className = $fileName -replace '\.cs$', ''
$result.Summary += "(deleted) $className`: Removed"
}
if ($IncludeContent) {
$result.DiffContent = git diff --cached 2>$null
if (-not $result.DiffContent) { $result.DiffContent = git diff 2>$null }
}
} catch { }
return $result
}
function Get-CommitChangesAnalysis {
<#
.SYNOPSIS
Analyze commits in a range and extract meaningful changes from diffs.
.PARAMETER Version
Version string to find commits for.
.PARAMETER CsprojPath
Path to csproj file.
.PARAMETER FileFilter
File extension filter (default: .cs).
.OUTPUTS
Array of change summary strings (like Get-UncommittedChanges).
#>
param(
[Parameter(Mandatory)][string]$Version,
[string]$CsprojPath = "*.csproj",
[string]$FileFilter = ".cs"
)
$summaries = @()
try {
# Get commit range for this version
$range = ""
# Find csproj
$actualCsprojPath = $CsprojPath
if ($CsprojPath -match '\*') {
$found = Get-ChildItem -Path $CsprojPath -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '\.csproj$' } |
Select-Object -First 1
if ($found) { $actualCsprojPath = $found.FullName }
}
# Get git path
$cwdPrefix = git rev-parse --show-prefix 2>$null
if ($cwdPrefix) { $cwdPrefix = $cwdPrefix.Trim().TrimEnd('/') }
$gitCsprojPath = $actualCsprojPath -replace '\\', '/'
if ([System.IO.Path]::IsPathRooted($actualCsprojPath)) {
Push-Location (Split-Path $actualCsprojPath -Parent) -ErrorAction SilentlyContinue
try {
$prefix = git rev-parse --show-prefix 2>$null
if ($prefix) {
$prefix = $prefix.Trim().TrimEnd('/')
$filename = Split-Path $actualCsprojPath -Leaf
$gitCsprojPath = if ($prefix) { "$prefix/$filename" } else { $filename }
}
} finally { Pop-Location -ErrorAction SilentlyContinue }
}
# Determine commit range
$versionCommit = Get-VersionBumpCommit -Version $Version -FilePath $CsprojPath
if ($versionCommit) {
$range = "$versionCommit^..HEAD"
}
else {
# Version not committed - find previous version
$committedContent = git show "HEAD:$gitCsprojPath" 2>$null
if ($committedContent) {
$prevVersionMatch = [regex]::Match(($committedContent -join "`n"), '([^<]+)')
if ($prevVersionMatch.Success) {
$prevVersion = $prevVersionMatch.Groups[1].Value
$prevCommit = Get-VersionBumpCommit -Version $prevVersion -FilePath $CsprojPath
if ($prevCommit) {
$range = "$prevCommit..HEAD"
}
}
}
if (-not $range) {
$lastTag = Get-LastTag
if ($lastTag) { $range = "$lastTag..HEAD" }
}
}
if (-not $range) { return @() }
# Get commits
$hashes = git log $range --format="%H" --no-merges -n 30 2>$null
if (-not $hashes) { return @() }
$hashArray = $hashes -split "`n" | Where-Object { $_.Trim() -ne "" }
foreach ($hash in $hashArray) {
$hash = $hash.Trim()
if (-not $hash) { continue }
$message = git log -1 --format="%s" $hash 2>$null
$shortHash = $hash.Substring(0, 7)
# Get files changed in this commit
$files = git diff-tree --no-commit-id --name-only -r $hash 2>$null
if (-not $files) { continue }
$sourceFiles = $files -split "`n" | Where-Object {
$_.Trim() -ne "" -and $_.EndsWith($FileFilter)
}
foreach ($file in $sourceFiles) {
$file = $file.Trim()
if (-not $file) { continue }
$fileName = Split-Path $file -Leaf
$className = $fileName -replace '\.cs$', ''
# Get diff for this file in this commit
$diff = git show $hash --format="" -- $file 2>$null
if ($diff) {
$changes = @()
$diffLines = $diff -split "`n"
foreach ($line in $diffLines) {
# Added method/class/property
if ($line -match '^\+\s*(public|private|protected|internal)\s+static\s+\w+\s+(\w+)\s*\(') {
$changes += "Added static method $($Matches[2])"
}
elseif ($line -match '^\+\s*(public|private|protected|internal)\s+\w+\s+(\w+)\s*\([^)]*\)\s*\{?') {
$methodName = $Matches[2]
if ($methodName -notmatch '^(get|set|if|for|while|switch|new|return)$') {
$changes += "Added method $methodName"
}
}
elseif ($line -match '^\+\s*(public|private|protected|internal)\s+(class|interface|struct|enum)\s+(\w+)') {
$changes += "Added $($Matches[2]) $($Matches[3])"
}
# Removed
elseif ($line -match '^-\s*(public|private|protected|internal)\s+(class|interface|struct|enum)\s+(\w+)') {
$changes += "Removed $($Matches[2]) $($Matches[3])"
}
elseif ($line -match '^-\s*(public|private|protected|internal)\s+\w+\s+(\w+)\s*\([^)]*\)\s*\{?') {
$methodName = $Matches[2]
if ($methodName -notmatch '^(get|set|if|for|while|switch|new|return)$') {
$changes += "Removed method $methodName"
}
}
# Exception handling
elseif ($line -match '^\+.*catch\s*\(') {
$changes += "Added exception handling"
}
}
$changes = $changes | Select-Object -Unique | Select-Object -First 4
if ($changes.Count -gt 0) {
$summaries += "(commit $shortHash) $className`: $($changes -join ', ')"
}
}
}
# Also add commit message context if no detailed changes found for any file
if (-not ($summaries | Where-Object { $_ -match $shortHash })) {
$summaries += "(commit $shortHash) $message"
}
}
} catch { }
return $summaries | Select-Object -Unique
}
# ==============================================================================
# CONSOLE OUTPUT HELPERS
# ==============================================================================
function Write-Banner {
<#
.SYNOPSIS
Write a banner/header to console.
.PARAMETER Title
Banner title text.
.PARAMETER Width
Banner width (default: 50).
.PARAMETER Color
Text color (default: Cyan).
#>
param(
[Parameter(Mandatory)][string]$Title,
[int]$Width = 50,
[string]$Color = "Cyan"
)
$border = "=" * $Width
Write-Host ""
Write-Host $border -ForegroundColor $Color
Write-Host $Title -ForegroundColor $Color
Write-Host $border -ForegroundColor $Color
Write-Host ""
}
function Write-Success {
<#
.SYNOPSIS
Write a success message.
#>
param([Parameter(Mandatory)][string]$Message)
Write-Host $Message -ForegroundColor Green
}
function Write-Warning {
<#
.SYNOPSIS
Write a warning message.
#>
param([Parameter(Mandatory)][string]$Message)
Write-Host "WARNING: $Message" -ForegroundColor Yellow
}
function Write-Failure {
<#
.SYNOPSIS
Write a failure/error message.
#>
param([Parameter(Mandatory)][string]$Message)
Write-Host "ERROR: $Message" -ForegroundColor Red
}
function Write-Info {
<#
.SYNOPSIS
Write an info message.
#>
param([Parameter(Mandatory)][string]$Message)
Write-Host $Message -ForegroundColor Gray
}
# ==============================================================================
# MODULE EXPORTS
# ==============================================================================
Export-ModuleMember -Function @(
# Step Timing
'Initialize-StepTimer'
'Start-Step'
'Complete-Step'
'Get-StepTimings'
'Show-TimingSummary'
# Prerequisites
'Test-CommandExists'
'Assert-Command'
'Assert-Commands'
'Test-EnvironmentVariable'
'Assert-EnvironmentVariable'
# Git Utilities
'Get-GitStatus'
'Show-GitStatus'
'Get-CurrentBranch'
'Get-LastTag'
'Get-CommitsSinceTag'
'Get-VersionBumpCommit'
'Get-CommitsForVersion'
'Get-DetailedCommits'
'Get-UncommittedChanges'
'Get-CommitChangesAnalysis'
# Console Output
'Write-Banner'
'Write-Success'
'Write-Warning'
'Write-Failure'
'Write-Info'
)