Instantly share code, notes, and snippets.
          Created
          September 26, 2025 14:38 
        
      - 
            
      
        
      
    Star
      
          0
          (0)
      
  
You must be signed in to star a gist  - 
              
      
        
      
    Fork
      
          0
          (0)
      
  
You must be signed in to fork a gist  
- 
        
Save HeyItsGilbert/86ebdc74563b126a737fb02be9db5338 to your computer and use it in GitHub Desktop.  
    Retroactively tag commits using changelog
  
        
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
  | # 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