GitSplit.psm1
|
<# GitSplit.psm1 This module contains git-oriented patch/hunk/commit splitting utilities used by ImmyBot tooling. It intentionally has no external dependencies beyond git being available on PATH. #> function Split-Patch { <# .SYNOPSIS Splits a unified diff/patch into per-file hunks. .DESCRIPTION Parses a text patch that contains one or more `diff --git` sections and returns an array of objects containing a file path and an array of unified diff hunk strings (each starting with an `@@ ... @@` header). This is used by PR/commit tooling to reason about changes at the hunk level. .PARAMETER patch The full patch text to split. This should be in unified diff format and include `diff --git` lines. .OUTPUTS System.Management.Automation.PSCustomObject Objects with properties: - FilePath (string): The path extracted from `a/<path> b/<path>`. - Patches (string[]): The hunks for that file. .EXAMPLE $patchText = git show --pretty=format: --no-color HEAD $files = Split-Patch -patch $patchText $files | Format-Table FilePath, @{n='Hunks';e={$_.Patches.Count}} #> param([string]$patch) # Split on diff --git lines first $files = $patch -split '(?m)^diff --git' # Skip empty first element if patch started with diff --git if ($files[0] -eq '') { $files = $files[1..$files.Length] } $result = @() foreach ($file in $files) { if ([string]::IsNullOrWhiteSpace($file)) { continue } # Extract file path from diff header if ($file -match 'a/(.+?)\s+b/') { $filePath = $matches[1] # Find all hunks starting with @@ header. # Use a lookahead for "\n@@" so we don't immediately terminate at the current header. $patches = [regex]::Matches( $file, '(?ms)^@@.*?(?=\n@@|\z)', [System.Text.RegularExpressions.RegexOptions]::Singleline ) | ForEach-Object { $_.Value } if ($patches.Count -gt 0) { $result += [PSCustomObject]@{ FilePath = $filePath Patches = $patches } } } } return $result } function Split-Hunk { <# .SYNOPSIS Splits a single unified diff hunk into two hunks. .DESCRIPTION Takes one unified diff hunk (a string beginning with `@@ -a,b +c,d @@`) and splits it into two valid hunks. You can split either: - By NEW-file line number (`-Line`), optionally at a specific column (`-Column`) to support mid-line splitting. - By body-line index (`-Index`), where the index is 0-based into the hunk body (not including the `@@` header). When splitting by column, this function currently supports mid-line splitting for context (' ') and added ('+') lines by converting a single body line into two body lines at the column boundary. .PARAMETER Hunk A single unified diff hunk string (not a full `diff --git` section). .PARAMETER Line The 1-based line number in the NEW file at which the second returned hunk should begin. .PARAMETER Column Optional 1-based column into the NEW-file line specified by `-Line`. When greater than 1, the target body line is split into two body lines at the column boundary. .PARAMETER Index 0-based index into the hunk body lines indicating the first body line of the second returned hunk. .OUTPUTS System.String[] Two hunk strings: the first half and the second half. .EXAMPLE $parts = Split-Hunk -Hunk $hunk -Line 10 $parts[0] | Out-Host $parts[1] | Out-Host .EXAMPLE # Mid-line split on NEW-file line 5, column 12 $parts = Split-Hunk -Hunk $hunk -Line 5 -Column 12 .NOTES This function assumes the input hunk header is valid and will throw if it cannot parse it. #> [CmdletBinding(DefaultParameterSetName = 'ByLine')] param( # A single unified diff hunk (the strings returned by Split-Patch's Patches array) [Parameter(Mandatory = $true, Position = 0)] [string]$Hunk, # Split before this 1-based line number in the NEW file ("+" side). [Parameter(Mandatory = $true, ParameterSetName = 'ByLine')] [ValidateRange(1, [int]::MaxValue)] [int]$Line, # Optional column (currently treated as a hint; split occurs on the specified line boundary). [Parameter(Mandatory = $false, ParameterSetName = 'ByLine')] [ValidateRange(1, [int]::MaxValue)] [int]$Column = 1, # Split at a 0-based index into the hunk BODY lines (not counting the @@ header line). # Index indicates the first body line that belongs to the SECOND returned hunk. [Parameter(Mandatory = $true, ParameterSetName = 'ByIndex')] [ValidateRange(0, [int]::MaxValue)] [int]$Index ) $text = $Hunk if ([string]::IsNullOrWhiteSpace($text)) { throw "Hunk is empty or invalid." } $firstNl = $text.IndexOf("`n") $header = if ($firstNl -ge 0) { $text.Substring(0, $firstNl) } else { $text } if ($header -notmatch '^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@') { throw "Hunk does not start with a valid @@ header: $header" } $oldStart = [int]$matches[1] $oldCount = if ($matches[2]) { [int]$matches[2] } else { 1 } $newStart = [int]$matches[3] $newCount = if ($matches[4]) { [int]$matches[4] } else { 1 } $bodyText = if ($firstNl -ge 0 -and $firstNl -lt ($text.Length - 1)) { $text.Substring($firstNl + 1) } else { '' } $body = @() if (-not [string]::IsNullOrEmpty($bodyText)) { $body = $bodyText -split "`n" if ($body.Count -gt 0 -and $body[-1] -eq '') { $body = $body[0..($body.Count - 2)] } } # Helper to count how a set of body lines affects old/new line counts. function Get-LineDeltas { param([string[]]$BodyLines) $o = 0 $n = 0 foreach ($l in $BodyLines) { if ($l.Length -eq 0) { # blank context line still counts as context (space prefix), but empty is ambiguous; treat as context. $o += 1 $n += 1 continue } $c = $l[0] switch ($c) { ' ' { $o += 1; $n += 1 } '-' { $o += 1 } '+' { $n += 1 } '\\' { } default { $o += 1; $n += 1 } } } return @{ Old = $o; New = $n } } $splitIndex = $null if ($PSCmdlet.ParameterSetName -eq 'ByIndex') { if ($Index -gt $body.Count) { throw "Index $Index is out of range for hunk body length $($body.Count)." } $splitIndex = $Index } else { # Split based on absolute new-file line. (Column is currently not used beyond validation.) $currentOld = $oldStart $currentNew = $newStart $splitIndex = $body.Count $didColumnSplit = $false # If Column > 1, we split the specific NEW-file line into two body lines at the column boundary. # This enables mid-line splitting by turning one '+' (or ' ') line into two lines. if ($Column -gt 1) { $targetBodyIndex = $null $targetPrefix = $null $tmpOld = $oldStart $tmpNew = $newStart for ($j = 0; $j -lt $body.Count; $j++) { $bl = $body[$j] if ($bl.Length -gt 0 -and $bl[0] -ne '\\') { if ($bl[0] -eq ' ' -or $bl[0] -eq '+') { if ($tmpNew -eq $Line) { $targetBodyIndex = $j $targetPrefix = $bl[0] break } } } if ($bl.Length -eq 0) { $tmpOld += 1 $tmpNew += 1 continue } switch ($bl[0]) { ' ' { $tmpOld += 1; $tmpNew += 1 } '-' { $tmpOld += 1 } '+' { $tmpNew += 1 } '\\' { } default { $tmpOld += 1; $tmpNew += 1 } } } if ($null -eq $targetBodyIndex) { throw "Could not locate NEW-file line $Line inside hunk body to split at Column $Column." } $original = $body[$targetBodyIndex] if ($original.Length -lt 2) { throw "Target line for mid-line split is too short to split: '$original'" } if ($targetPrefix -ne ' ' -and $targetPrefix -ne '+') { throw "Mid-line split currently supports only context (' ') or added ('+') lines." } $content = $original.Substring(1) $splitAt = $Column - 1 if ($splitAt -le 0 -or $splitAt -ge ($content.Length + 1)) { throw "Column $Column is out of range for line content length $($content.Length)." } $left = $content.Substring(0, [Math]::Min($splitAt, $content.Length)) $right = if ($splitAt -lt $content.Length) { $content.Substring($splitAt) } else { '' } $line1 = "$targetPrefix$left" $line2 = "$targetPrefix$right" # Replace one line with two lines. $pre = if ($targetBodyIndex -gt 0) { $body[0..($targetBodyIndex - 1)] } else { @() } $post = if ($targetBodyIndex -lt ($body.Count - 1)) { $body[($targetBodyIndex + 1)..($body.Count - 1)] } else { @() } $body = @($pre + @($line1, $line2) + $post) # The second hunk starts at the inserted second line. $splitIndex = $targetBodyIndex + 1 # We deliberately chose the split boundary; don't let the line-based scan override it. $didColumnSplit = $true } if (-not $didColumnSplit) { for ($i = 0; $i -lt $body.Count; $i++) { $l = $body[$i] # Decide which hunk this line belongs to by the current NEW-file line position. # If this line affects a new-file line >= target Line, it starts the second hunk. if ($currentNew -ge $Line) { $splitIndex = $i break } if ($l.Length -eq 0) { $currentOld += 1 $currentNew += 1 continue } switch ($l[0]) { ' ' { $currentOld += 1; $currentNew += 1 } '-' { $currentOld += 1 } '+' { $currentNew += 1 } '\\' { } default { $currentOld += 1; $currentNew += 1 } } } } } if ($splitIndex -le 0 -or $splitIndex -ge $body.Count) { throw "Split point must be inside the hunk body (cannot split at start or end). Computed splitIndex=$splitIndex for body length $($body.Count)." } $body1 = @() $body2 = @() if ($splitIndex -gt 0) { $body1 = $body[0..($splitIndex - 1)] } if ($splitIndex -lt $body.Count) { $body2 = $body[$splitIndex..($body.Count - 1)] } $d1 = Get-LineDeltas -BodyLines $body1 $oldStart2 = $oldStart + $d1.Old $newStart2 = $newStart + $d1.New $d2 = Get-LineDeltas -BodyLines $body2 $h1 = New-Hunk -OldStart $oldStart -OldCount $d1.Old -NewStart $newStart -NewCount $d1.New -BodyLines $body1 $h2 = New-Hunk -OldStart $oldStart2 -OldCount $d2.Old -NewStart $newStart2 -NewCount $d2.New -BodyLines $body2 return @($h1, $h2) } function New-Hunk { <# .SYNOPSIS Builds a unified diff hunk string from header coordinates and body lines. .DESCRIPTION Constructs a well-formed unified diff hunk: @@ -<OldStart>,<OldCount> +<NewStart>,<NewCount> @@ <body...> This helper centralizes hunk formatting rules, including the important detail that a truly blank context line must be represented as a single space character (' '), not an empty string. .PARAMETER OldStart 1-based start line number in the OLD file (the '-' side). .PARAMETER OldCount Number of old-file lines covered by this hunk. .PARAMETER NewStart 1-based start line number in the NEW file (the '+' side). .PARAMETER NewCount Number of new-file lines covered by this hunk. .PARAMETER BodyLines Array of hunk body lines (not including the header). Each element should typically start with: ' ' (context), '+' (add), '-' (remove), or '\\' (no-newline marker). .OUTPUTS System.String The constructed hunk text (including a trailing newline). .EXAMPLE $hunk = New-Hunk -OldStart 1 -OldCount 1 -NewStart 1 -NewCount 2 -BodyLines @(' line1', '+line2') #> [CmdletBinding()] param( # 1-based start line in the OLD file (the '-' side). [Parameter(Mandatory = $true)] [ValidateRange(1, [int]::MaxValue)] [int]$OldStart, # Number of lines in the OLD file covered by this hunk. [Parameter(Mandatory = $true)] [ValidateRange(0, [int]::MaxValue)] [int]$OldCount, # 1-based start line in the NEW file (the '+' side). [Parameter(Mandatory = $true)] [ValidateRange(1, [int]::MaxValue)] [int]$NewStart, # Number of lines in the NEW file covered by this hunk. [Parameter(Mandatory = $true)] [ValidateRange(0, [int]::MaxValue)] [int]$NewCount, # Body lines of the hunk (not including the @@ header). Typically each line begins with ' ', '+', '-', or '\\'. [Parameter(Mandatory = $false)] [AllowNull()] [string[]]$BodyLines ) $header = "@@ -$OldStart,$OldCount +$NewStart,$NewCount @@" if (-not $BodyLines -or $BodyLines.Count -eq 0) { return $header + "`n" } # Unified diff hunk body lines must start with one of: ' ' (context), '+' (add), '-' (remove), or '\\' (no newline marker). # A truly blank context line is represented by a single space character, NOT an empty string. return $header + "`n" + (($BodyLines | ForEach-Object { if ($null -eq $_) { return ' ' } if ($_.Length -eq 0) { return ' ' } return $_ }) -join "`n") + "`n" } function New-Range { <# .SYNOPSIS Creates a range object that can convert between (Line, Column) and Index for a file. .DESCRIPTION Builds a simple range object for a file path that supports conversion between: - 1-based (Line, Column) coordinates, and - 0-based character Index into the file content. The file is read as-is (no newline normalization). This means indexes are based on the exact content returned by `Get-Content -Raw`. The returned object caches its `ToString()` value to avoid surprises if properties are later mutated. .PARAMETER Path Path to the file to base the range calculations on. .PARAMETER Line 1-based line number. .PARAMETER Column 1-based column number. .PARAMETER Index 0-based character index into the file content. .PARAMETER Length Length (in characters). This module currently uses Length primarily for bookkeeping/tests. .OUTPUTS System.Management.Automation.PSCustomObject Object with properties: Path, Line, Column, Index, Length. .EXAMPLE # From line/column to index $r = New-Range -Path './b.txt' -Line 2 -Column 5 -Length 3 $r.Index .EXAMPLE # From index to line/column $r = New-Range -Path './b.txt' -Index 10 -Length 1 "$($r.Line):$($r.Column)" #> [CmdletBinding(DefaultParameterSetName = 'ByLineColumn')] param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true, ParameterSetName = 'ByLineColumn')] [ValidateRange(1, [int]::MaxValue)] [int]$Line, [Parameter(Mandatory = $true, ParameterSetName = 'ByLineColumn')] [ValidateRange(1, [int]::MaxValue)] [int]$Column, # 0-based index into the file contents. [Parameter(Mandatory = $true, ParameterSetName = 'ByIndex')] [ValidateRange(0, [int]::MaxValue)] [int]$Index, [Parameter(Mandatory = $true)] [ValidateRange(0, [int]::MaxValue)] [int]$Length ) if (-not (Test-Path -LiteralPath $Path)) { throw "Path not found: $Path" } # Use the file contents as-is (no newline normalization); indexes are in characters. $text = Get-Content -LiteralPath $Path -Raw # Precompute line starts (0-based indices) for fast conversion. $lineStarts = New-Object System.Collections.Generic.List[int] $lineStarts.Add(0) | Out-Null for ($i = 0; $i -lt $text.Length; $i++) { if ($text[$i] -eq "`n") { $lineStarts.Add($i + 1) | Out-Null } } function Resolve-IndexFromLineColumn { param( [int]$InLine, [int]$InColumn ) if ($InLine -gt $lineStarts.Count) { throw "Line $InLine is out of range for file '$Path' which has $($lineStarts.Count) line(s)." } $start = $lineStarts[$InLine - 1] $idx = $start + ($InColumn - 1) if ($idx -lt 0 -or $idx -gt $text.Length) { throw "Line/Column ($InLine,$InColumn) resolves to index $idx which is out of range for file '$Path' length $($text.Length)." } return $idx } function Resolve-LineColumnFromIndex { param( [int]$InIndex ) if ($InIndex -lt 0 -or $InIndex -gt $text.Length) { throw "Index $InIndex is out of range for file '$Path' length $($text.Length)." } # Find the last line start <= index. $lineNumber = 1 $lineStart = 0 for ($j = 0; $j -lt $lineStarts.Count; $j++) { $s = $lineStarts[$j] if ($s -le $InIndex) { $lineNumber = $j + 1 $lineStart = $s } else { break } } $col = ($InIndex - $lineStart) + 1 return @{ Line = $lineNumber; Column = $col } } if ($PSCmdlet.ParameterSetName -eq 'ByIndex') { $lc = Resolve-LineColumnFromIndex -InIndex $Index $Line = [int]$lc.Line $Column = [int]$lc.Column } else { $Index = Resolve-IndexFromLineColumn -InLine $Line -InColumn $Column } $cached = "${Path}:${Line}:${Column}+${Length}" $obj = [PSCustomObject]@{ Path = $Path Line = $Line Column = $Column Index = $Index Length = $Length } # Cache ToString() output so it doesn't depend on later property mutations. $obj | Add-Member -MemberType NoteProperty -Name '_ToString' -Value $cached -Force $obj | Add-Member -MemberType ScriptMethod -Name 'ToString' -Value { $this._ToString } -Force return $obj } function Split-Commit { <# .SYNOPSIS Splits a single git commit into multiple commits by splitting hunks. .DESCRIPTION Rewrites git history by taking the commit identified by `-Ref`, splitting one or more file hunks at specified NEW-file line/column split points, then recreating the original commit as multiple commits ("split pieces"). After the split commits are created, any commits that were originally after the target commit are cherry-picked back on top, preserving the overall history (but with a different commit graph). This is intended for developer workflow tooling and should be used with care. .PARAMETER Ref The commit-ish to split (e.g. 'HEAD' or a SHA). .PARAMETER NewCommitRanges One or more split point objects. Each object must include: - Path : file path (as seen in the patch, e.g. 'src/file.txt') - Line : 1-based NEW-file line number where the next split piece begins Optional: - Column : 1-based column for mid-line splitting (defaults to 1) - Length : currently ignored (reserved for future range splitting) .OUTPUTS System.String[] An array of SHAs for the split commits created (in creation order). .EXAMPLE # Split HEAD's b.txt changes so NEW-file line 2 begins a new commit $created = Split-Commit -Ref HEAD -NewCommitRanges @( [pscustomobject]@{ Path = 'b.txt'; Line = 2 } ) $created .NOTES - This command performs `git reset --hard` and `git cherry-pick`, and will rewrite commits. - Run this only on local branches (or be prepared to force push). - Currently supports splitting only files that have exactly one hunk in the target commit. #> [CmdletBinding()] [OutputType([string[]])] param( # Commit to split (commit-ish). Typically use HEAD. [Parameter(Mandatory = $true)] [string]$Ref, # One or more split points. # Each element must include: # - Path # - Line (1-based, NEW-file line number) # Optional: # - Column (1-based; defaults to 1) # - Length (currently ignored; reserved for future range splitting) [Parameter(Mandatory = $true)] [object[]]$NewCommitRanges ) $repoRoot = (git rev-parse --show-toplevel) if ($LASTEXITCODE -ne 0 -or -not $repoRoot) { throw "Split-Commit must be run inside a git repository." } $oldHead = (git rev-parse HEAD) if ($LASTEXITCODE -ne 0 -or -not $oldHead) { throw "Unable to determine HEAD." } $target = (git rev-parse $Ref) if ($LASTEXITCODE -ne 0 -or -not $target) { throw "Unable to resolve Ref '$Ref'." } $parent = (git rev-parse "$target^") if ($LASTEXITCODE -ne 0 -or -not $parent) { throw "Unable to resolve parent for Ref '$Ref' ($target)." } $subject = (git log -1 --pretty=format:%s $target) if ($LASTEXITCODE -ne 0 -or -not $subject) { $subject = "Split $target" } $createdSplitCommits = New-Object System.Collections.Generic.List[string] # Collect commits after the target (if any) so we can replay them. $afterTarget = @(git rev-list --reverse "$target..$oldHead") if ($LASTEXITCODE -ne 0) { throw "git rev-list failed for range $target..$oldHead with exit code $LASTEXITCODE" } if ($afterTarget.Count -eq 1 -and $afterTarget[0] -is [string] -and $afterTarget[0] -match "\r?\n") { $afterTarget = $afterTarget[0] -split "\r?\n" } $afterTarget = @($afterTarget | Where-Object { $_ -and $_.Trim() }) # Get the patch for the commit we are splitting. $patchText = @( git show --pretty=format: --no-color $target ) -join "`n" if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($patchText)) { throw "git show failed to produce patch for $target." } # Parse hunks per file. $filePatches = Split-Patch -patch $patchText if (-not $filePatches -or $filePatches.Count -eq 0) { throw "No file patches found in commit $target." } # Group split points by file path. $rangesByPath = @{} foreach ($r in $NewCommitRanges) { if ($null -eq $r) { continue } $p = $r.Path if (-not $p) { throw "NewCommitRanges elements must include Path." } if (-not $rangesByPath.ContainsKey($p)) { $rangesByPath[$p] = @() } $rangesByPath[$p] += $r } # Helper: extract a single file's diff section from a combined patch. function Get-FileDiffSection { param( [string]$CombinedPatch, [string]$FilePath ) $parts = $CombinedPatch -split '(?m)^diff --git ' if ($parts[0] -eq '') { $parts = $parts[1..($parts.Length - 1)] } foreach ($part in $parts) { if ([string]::IsNullOrWhiteSpace($part)) { continue } $section = "diff --git $part" $escaped = [regex]::Escape($FilePath) if ($section -match "(?m)^diff --git a/$escaped b/$escaped$") { return $section } # fallback: try the original extraction regex used by Split-Patch if ($section -match 'a/(.+?)\s+b/' -and $matches[1] -eq $FilePath) { return $section } } return $null } # Compute per-file pieces (hunk fragments) based on split points. $perFilePieces = @{} foreach ($fp in $filePatches) { $path = $fp.FilePath $hunks = @($fp.Patches) # Default: no split points => whole file diff is one piece. $splitPoints = @() if ($rangesByPath.ContainsKey($path)) { $splitPoints = @($rangesByPath[$path] | Where-Object { $_.Line } | Sort-Object { [int]$_.Line }) } # For now we only support a single hunk per file for splitting. if ($splitPoints.Count -gt 0 -and $hunks.Count -ne 1) { throw "Split-Commit currently supports splitting only files with exactly 1 hunk. File '$path' has $($hunks.Count)." } if ($splitPoints.Count -eq 0) { $perFilePieces[$path] = @($hunks) continue } $pieces = @($hunks[0]) foreach ($sp in $splitPoints) { if (-not ($sp.PSObject.Properties.Name -contains 'Line') -or $null -eq $sp.Line -or [string]::IsNullOrWhiteSpace([string]$sp.Line)) { throw "Split-Commit: NewCommitRanges elements must include Line for path '$path'." } $line = [int]$sp.Line $col = if ($sp.PSObject.Properties.Name -contains 'Column' -and $sp.Column) { [int]$sp.Column } else { 1 } # Find the current piece that contains the line; for simplicity, split the last piece. $last = $pieces[-1] $split = Split-Hunk -Hunk $last -Line $line -Column $col # Replace the last piece with its two halves. if ($pieces.Count -le 1) { $pieces = @($split) } else { $prefixPieces = $pieces[0..($pieces.Count - 2)] $pieces = @($prefixPieces + $split) } } $perFilePieces[$path] = $pieces } # Determine how many commits we will create (max number of pieces across files). $pieceCount = 1 foreach ($k in $perFilePieces.Keys) { $c = @($perFilePieces[$k]).Count if ($c -gt $pieceCount) { $pieceCount = $c } } # Rewrite: go to parent, apply each piece-set as its own commit, then replay remaining commits. try { git reset --hard $parent | Out-Null if ($LASTEXITCODE -ne 0) { throw "git reset --hard $parent failed with exit code $LASTEXITCODE" } for ($i = 0; $i -lt $pieceCount; $i++) { # Build a combined patch for this piece index. $combined = "" foreach ($fp in $filePatches) { $path = $fp.FilePath $section = Get-FileDiffSection -CombinedPatch $patchText -FilePath $path if (-not $section) { throw "Unable to locate diff section for '$path' in commit patch." } $pieces = @($perFilePieces[$path]) $h = $null if ($i -lt $pieces.Count) { $h = $pieces[$i] } else { # This file doesn't contribute to this commit piece. continue } # If this hunk piece contains no actual changes (only context), omit it. # git apply can reject no-op hunks as corrupt, and we don't want to create empty commits. if ($h -notmatch '(?m)^[+-](?![+-]{2})') { continue } # Replace the hunk portion with this piece. $idx = $section.IndexOf("@@") if ($idx -lt 0) { throw "Diff section for '$path' did not contain a hunk header." } $prefix = $section.Substring(0, $idx) # Drop blob hash lines since splitting changes the resulting blob. $prefix = $prefix -replace '(?m)^index .*\r?\n', '' # Do not insert extra blank lines: unified diff hunks cannot contain raw empty lines. # Ensure we end with exactly one newline between sections. # IMPORTANT: Trim only newlines, not whitespace. A blank context line in a unified diff is a single space # character, and TrimEnd() would remove that space and corrupt the patch. $combined += ($prefix + $h.TrimEnd("`r", "`n") + "`n") } if ([string]::IsNullOrWhiteSpace($combined)) { continue } $tmp = Join-Path $repoRoot (".split-commit.$i.patch") # Avoid Set-Content appending an extra newline, which can introduce a raw blank line at EOF # and make `git apply` reject the patch as corrupt. Set-Content -LiteralPath $tmp -Value $combined -Encoding utf8 -NoNewline $keepSplitPatch = ($env:IMMYBUILD_KEEP_SPLIT_PATCH -eq '1') -or ($env:IMMYBUILD_KEEP_TEMPREPO -eq '1') # Split patches intentionally contain less context; apply them deterministically. git apply --whitespace=nowarn --unidiff-zero $tmp | Out-Null if ($LASTEXITCODE -ne 0) { if (-not $keepSplitPatch) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } throw "git apply failed for split patch piece $i (file $tmp) with exit code $LASTEXITCODE" } if (-not $keepSplitPatch) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } git add -A | Out-Null # If the split piece results in no changes (can happen depending on split points), skip creating a commit. git diff --cached --quiet if ($LASTEXITCODE -eq 0) { continue } $msg = "$subject (split $($i + 1)/$pieceCount)" git commit -m $msg | Out-Null if ($LASTEXITCODE -ne 0) { throw "git commit failed while creating split commit piece $i with exit code $LASTEXITCODE" } $newSha = (git rev-parse HEAD) if ($LASTEXITCODE -ne 0 -or -not $newSha) { throw "Unable to resolve SHA for newly created split commit piece $i." } $createdSplitCommits.Add($newSha.Trim()) | Out-Null } foreach ($c in $afterTarget) { git cherry-pick $c | Out-Null if ($LASTEXITCODE -ne 0) { throw "git cherry-pick failed for $c with exit code $LASTEXITCODE" } } } catch { throw } return @($createdSplitCommits) } function Add-Commit { <# .SYNOPSIS Deterministically inserts a new commit by applying a patch while replaying history. .DESCRIPTION Rewrites history starting "after" a given commit-ish by: 1) resetting to the `-After` commit, 2) cherry-picking a small number of subsequent commits to reach the intended insertion point, 3) applying `-PatchFile` and committing it with `-CommitMessage`, 4) cherry-picking any remaining commits. This avoids interactive rebase editor flows, which can be brittle across environments. .PARAMETER RepoPath Path to the git repository to operate on. Defaults to the current directory. .PARAMETER After The commit-ish *before* the range to rewrite (e.g. 'HEAD~2'). The rewrite starts from this commit. .PARAMETER PatchFile Path to a patch file to apply (unified diff). .PARAMETER CommitMessage Commit message to use for the inserted patch commit. .EXAMPLE Add-Commit -After HEAD~3 -PatchFile ./fix.patch -CommitMessage "Fix lint" .NOTES This command rewrites history and may require force pushing if run on a published branch. #> [CmdletBinding()] param( # Path to the git repository to operate on. [Parameter(Mandatory = $false)] [string]$RepoPath, # The commit-ish *before* the range we want to rewrite (e.g. HEAD~2) [Parameter(Mandatory = $true)] [string]$After, # Patch file to apply while paused at the newer commit. [Parameter(Mandatory = $true)] [string]$PatchFile, # Commit message for the patch commit. [Parameter(Mandatory = $true)] [string]$CommitMessage ) if (-not $RepoPath) { $RepoPath = (Get-Location).Path } $oldSeq = $env:GIT_SEQUENCE_EDITOR $oldEd = $env:GIT_EDITOR Push-Location $RepoPath try { # Implement the desired "stop at newer commit" behavior deterministically without relying on # interactive rebase editors (which can be brittle in CI / different git versions). # # 1) Enumerate commits to replay (oldest -> newest) # 2) Reset to Upstream # 3) Cherry-pick the older commit(s) # 4) Cherry-pick the newer commit (our "stop" point) # 5) Apply patches + commit # 6) Cherry-pick any remaining commits $env:GIT_SEQUENCE_EDITOR = $null $env:GIT_EDITOR = ':' $commits = @(git rev-list --reverse "$After..HEAD") if ($LASTEXITCODE -ne 0) { throw "git rev-list failed for range $After..HEAD with exit code $LASTEXITCODE" } if ($commits.Count -eq 1 -and $commits[0] -is [string] -and $commits[0] -match "\r?\n") { $commits = $commits[0] -split "\r?\n" } $commits = @($commits | Where-Object { $_ -and $_.Trim() }) if ($commits.Count -lt 1) { throw "Expected at least 1 commit to replay in range $After..HEAD, found $($commits.Count)." } $olderCommit = $null $newerCommit = $commits[0] $remainingCommits = @() if ($commits.Count -ge 2) { $olderCommit = $commits[0] $newerCommit = $commits[1] if ($commits.Count -gt 2) { $remainingCommits = $commits[2..($commits.Count - 1)] } } elseif ($commits.Count -eq 1) { # With only one commit in the range, that commit is effectively the "newer" stop point. $olderCommit = $null $newerCommit = $commits[0] $remainingCommits = @() } git reset --hard $After | Out-Null if ($LASTEXITCODE -ne 0) { throw "git reset --hard $After failed with exit code $LASTEXITCODE" } if ($olderCommit) { git cherry-pick $olderCommit | Out-Null if ($LASTEXITCODE -ne 0) { throw "git cherry-pick (older) failed for $olderCommit with exit code $LASTEXITCODE" } } git cherry-pick $newerCommit | Out-Null if ($LASTEXITCODE -ne 0) { throw "git cherry-pick (newer) failed for $newerCommit with exit code $LASTEXITCODE" } if (-not (Test-Path $PatchFile)) { throw "Patch file not found: $PatchFile" } # Apply patch in a way that tolerates rewritten history (index/blob hashes may differ). git apply --whitespace=nowarn $PatchFile | Out-Null if ($LASTEXITCODE -ne 0) { git apply --whitespace=nowarn --3way $PatchFile | Out-Null if ($LASTEXITCODE -ne 0) { throw "git apply failed for $PatchFile with exit code $LASTEXITCODE" } } git add -A | Out-Null git commit -m $CommitMessage | Out-Null if ($LASTEXITCODE -ne 0) { throw "git commit failed for patch $PatchFile (message: $CommitMessage) with exit code $LASTEXITCODE" } foreach ($c in $remainingCommits) { git cherry-pick $c | Out-Null if ($LASTEXITCODE -ne 0) { throw "git cherry-pick (remaining) failed for $c with exit code $LASTEXITCODE" } } } finally { Pop-Location $env:GIT_SEQUENCE_EDITOR = $oldSeq $env:GIT_EDITOR = $oldEd } } Export-ModuleMember -Function Split-Patch, Split-Hunk, New-Hunk, New-Range, Split-Commit, Add-Commit |