Skip to content

Instantly share code, notes, and snippets.

@HeyItsGilbert
Created September 26, 2025 14:38
Show Gist options
  • Save HeyItsGilbert/86ebdc74563b126a737fb02be9db5338 to your computer and use it in GitHub Desktop.
Save HeyItsGilbert/86ebdc74563b126a737fb02be9db5338 to your computer and use it in GitHub Desktop.
Retroactively tag commits using changelog
# spell-checker:ignore Markdig
#Requires -Version 7.0
function Set-GitTagFromChangeLog {
[CmdletBinding(SupportsShouldProcess = $true)]
param (
[ValidateScript({ Test-Path $_ })]
[string]
$ChangeLogPath = '.\CHANGELOG.md'
)
# This script will loop through each changelog entry and create a tag for it.
# It assumes that the changelog is in the format of Keep a Changelog.
# It also assumes that the changelog is in the root of the repository.
$md = ConvertFrom-Markdown $ChangeLogPath
$content = Get-Content $ChangeLogPath
# Get level 2 headers
$headers = $md.Tokens | Where-Object { $_.GetType() -eq [Markdig.Syntax.HeadingBlock] -and $_.Level -eq 2 }
for ($i = 0; $i -lt $headers.Count; $i++) {
Write-Verbose "Processing header $($i + 1) of $($headers.Count)"
$header = $headers[$i]
# Get the version from the header text. It should be either: [0.1.0] or \[0.1.0\]
if ($content[$header.Line].Trim() -match '^\s*##\s*\\?\[(?<version>[0-9]+\.[0-9]+\.[0-9]+)\\?].*$' -and -not $matches['version']) {
Write-Warning "Could not parse version from header line: $($content[$header.Line])"
continue
}
$version = $matches.version
if ($version -eq 'Unreleased') {
continue
}
# Get the line number of the header
$lineNumber = $header.Line + 1
$nextHeader = $headers[$i + 1].Line - 1
# For each line in the sections between the headers, do a git blame and get the oldest commit hash
$blameInfo = Get-GitBlameForLineRange -FilePath $ChangeLogPath -StartLine $lineNumber -EndLine $nextHeader
if (-not $blameInfo) {
Write-Warning "No blame information found for lines $lineNumber to $nextHeader in $ChangeLogPath"
continue
}
if ($PSBoundParameters['Verbose']) {
$blameInfo | ForEach-Object {
Write-Verbose "Blame Info: CommitHash=$($_.CommitHash), Author=$($_.Author), Timestamp=$($_.Timestamp), LineNumber=$($_.LineNumber), LineText=$($_.LineText)"
}
}
$oldestCommit = $blameInfo | Sort-Object { $_.Timestamp } | Select-Object -First 1
Write-Verbose "Oldest commit for version $version is $($oldestCommit.CommitHash) on $($oldestCommit.Timestamp)"
if ($oldestCommit) {
$commitHash = $oldestCommit.CommitHash
Write-Host "Creating tag $version for commit $commitHash"
if ($PSCmdlet.ShouldProcess("$commitHash", "Tag with version $version")) {
New-GitTag -Version $version -CommitHash $commitHash
}
}
else {
Write-Warning "Could not find commit hash for version $version"
}
}
}
function Get-GitBlameForLineRange {
[CmdletBinding(SupportsShouldProcess = $true)]
param (
[string] $FilePath,
[int] $StartLine,
[int] $EndLine,
[int]$TimeLimitSeconds = 120
)
$arguments = @('blame', '-L', "$($StartLine),$($EndLine)", '-t', '--first-parent', $FilePath)
$processInfo = New-Object -TypeName 'System.Diagnostics.ProcessStartInfo'
$processInfo.FileName = 'git'
$processInfo.WorkingDirectory = $WorkingDirectory
$processInfo.RedirectStandardError = $true
$processInfo.RedirectStandardOutput = $true
$processInfo.UseShellExecute = $false
$processInfo.Arguments = $arguments -join ' '
Write-Debug ($arguments -join ' ')
$process = New-Object -TypeName 'System.Diagnostics.Process'
$process.StartInfo = $processInfo
Write-Verbose "Starting git process: $($processInfo.FileName) $($processInfo.Arguments)"
$null = $process.Start()
$timedOut = -not $process.WaitForExit($TimeLimitSeconds * 1000)
# If timed out forcibly terminate to avoid zombie processes
if ($timedOut -and -not $process.HasExited) {
Write-Verbose 'Git process timed out, attempting to kill it.'
try {
$process.Kill()
}
catch {
Write-Verbose 'Failed to kill timed-out Git process.'
}
$process.WaitForExit()
}
[string]$standardOutput = $process.StandardOutput.ReadToEnd().Trim()
[string]$standardError = $process.StandardError.ReadToEnd().Trim()
Write-Debug "Git process exited with code $($process.ExitCode)."
if ($timedOut) {
Write-Error "Git process timed out after $TimeLimitSeconds seconds."
return $null
}
if ($process.ExitCode -ne 0) {
Write-Error "Git process failed with exit code $($process.ExitCode). Error: $standardError"
return $null
}
if ([string]::IsNullOrWhiteSpace($standardOutput)) {
Write-Verbose 'Git process returned no output.'
return $null
}
Write-Debug "Git process output:`n$standardOutput"
# Format each line into an object
# Looks like: 0625ff79 (Trent Blackburn 1727211210 -0400 600) ## \[0.1.7\] Use `Start-Job` for Background Jobs
$output = $standardOutput.Split([Environment]::NewLine, [StringSplitOptions]::RemoveEmptyEntries) | ForEach-Object {
Write-Debug "Processing line: $_"
if ($_ -match '^(?<CommitHash>[0-9a-f]{7,40}) \((?<Author>.+?)\s+(?<Timestamp>\d+)\s+(?<Timezone>[+-]\d{4})\s+(?<LineNumber>\d+)\) (?<LineText>.+)$') {
[PSCustomObject]@{
CommitHash = $Matches.CommitHash
Author = $Matches.Author.Trim()
Timestamp = [System.DateTimeOffset]::FromUnixTimeSeconds($Matches.Timestamp).DateTime
Timezone = $Matches.Timezone
LineNumber = [int]$Matches.LineNumber
LineText = $Matches.LineText
}
}
}
return $output
}
function New-GitTag {
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param (
[string] $Version,
[string] $CommitHash
)
# Set the HEAD to the old commit that we want to tag
if ($PSCmdlet.ShouldProcess($CommitHash, 'git checkout')) {
git checkout $CommitHash
}
# temporarily set the date to the date of the HEAD commit, and add the tag
if ($PSCmdlet.ShouldProcess($Version, 'Tag commit with version')) {
$env:GIT_COMMITTER_DATE="$((git show --format=%aD)[0])"
git tag -a $Version -m"$Version"
}
# push to origin
if ($PSCmdlet.ShouldProcess('git push origin --tags', 'Push tags to origin')) {
git push origin --tags
}
# set HEAD back to whatever you want it to be
if ($PSCmdlet.ShouldProcess('Default Branch', 'Checkout')) {
git checkout $(git default-branch)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment