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.
#>


# Internal helper to run `git` in a way that does not leak informational stderr output to the host
# (which some runners surface as error notifications), while still including stderr when the command fails.
function Invoke-Git {
  [CmdletBinding()]
  param(
    # Optional error message/context used when throwing.
    [Parameter()]
    [string]$ErrorMessage,

    # If set, suppresses output to the host.
    [Parameter()]
    [switch]$Quiet,

    # If set, prints captured output to host in red on failure *before* throwing.
    # This keeps diagnostics out of the PowerShell error stream while still failing fast.
    [Parameter()]
    [switch]$WriteHostOnError,

    # Arguments to pass to git as discrete tokens.
    # Use "ValueFromRemainingArguments" so callers can use normal syntax:
    # Invoke-Git -Quiet reset --hard HEAD~1
    [Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)]
    [string[]]$GitArgs
  )

  # Capture BOTH stdout+stderr so we can (a) avoid leaking stderr on success and
  # (b) still show useful diagnostics on failure.
  # PowerShell wraps native stderr lines as ErrorRecord objects even when redirected with 2>&1.
  # Normalize everything to plain strings so callers/hosts don't treat stderr text as PowerShell errors.
  # Use the pipeline so we can optionally stream output in real time.
  & git @GitArgs 2>&1 | ForEach-Object {
    if (!$Quiet) {
      if ($WriteHostOnError -and $_ -is [System.Management.Automation.ErrorRecord]) { 
        $_ | Out-String | Write-Host -ForegroundColor Red
      }
      else { 
        # Keep output visible without using the error stream.
        $_ | Out-String | Write-Host
      }
    }
  }
  
  $exitCode = $LASTEXITCODE

  if ($exitCode -ne 0) {
    $ctx = if ($ErrorMessage) { $ErrorMessage } else { "git $($GitArgs -join ' ')" }
    $details = ($output | Where-Object { $_ -ne $null }) -join [Environment]::NewLine

    if (-not $Quiet -and $WriteHostOnError -and -not [string]::IsNullOrWhiteSpace($details)) {
      Write-Host $details -ForegroundColor Red
    }

    if ([string]::IsNullOrWhiteSpace($details) -or $WriteHostOnError) {
      throw "$ctx failed with exit code $exitCode"
    }

    throw "$ctx failed with exit code $exitCode`n$details"
  }
}

# Internal helper to suppress PowerShell progress UI for noisy operations (e.g., Remove-Item).
function Invoke-WithProgressSuppressed {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [scriptblock]$Script
  )

  $old = $global:ProgressPreference
  try {
    $global:ProgressPreference = 'SilentlyContinue'
    & $Script
  }
  finally {
    $global:ProgressPreference = $old
  }
}

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]
  $newStart = [int]$matches[3]

  # Note: we intentionally don't use the original header counts here; we recompute
  # old/new counts from the body lines when building the split hunks.

  $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 {
    Invoke-Git -ErrorMessage "git reset --hard $parent" reset --hard $parent

    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.
      try {
        Invoke-Git -ErrorMessage "git apply failed for split patch piece $i (file $tmp)" apply --whitespace=nowarn --unidiff-zero $tmp
      }
      catch {
        if (-not $keepSplitPatch) {
          Invoke-WithProgressSuppressed {
            Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
          }
        }
        throw
      }
      if (-not $keepSplitPatch) {
        Invoke-WithProgressSuppressed {
          Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
        }
      }

      Invoke-Git -ErrorMessage 'git add -A' add -A

      # 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)"
      Invoke-Git -ErrorMessage "git commit failed while creating split commit piece $i" commit -m $msg

      $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) {
      Invoke-Git -ErrorMessage "git cherry-pick failed for $c" cherry-pick $c
    }
  }
  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) {
      # Preserve existing behavior for callers that rely on stdout/stderr of reset.
      throw "git reset --hard $After failed with exit code $LASTEXITCODE"
    }

    if ($olderCommit) {
      Invoke-Git -ErrorMessage "git cherry-pick (older) failed for $olderCommit" cherry-pick $olderCommit
    }

    Invoke-Git -ErrorMessage "git cherry-pick (newer) failed for $newerCommit" cherry-pick $newerCommit

    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).
    try {
      Invoke-Git -ErrorMessage "git apply failed for $PatchFile" apply --whitespace=nowarn $PatchFile
    }
    catch {
      # Retry with 3-way apply; if this fails, bubble up diagnostics.
      Invoke-Git -ErrorMessage "git apply --3way failed for $PatchFile" apply --whitespace=nowarn --3way $PatchFile
    }

    Invoke-Git -ErrorMessage 'git add -A' add -A
    Invoke-Git -ErrorMessage "git commit failed for patch $PatchFile (message: $CommitMessage)" commit -m $CommitMessage

    foreach ($c in $remainingCommits) {
      Invoke-Git -ErrorMessage "git cherry-pick (remaining) failed for $c" cherry-pick $c
    }
  }
  finally {
    Pop-Location
    $env:GIT_SEQUENCE_EDITOR = $oldSeq
    $env:GIT_EDITOR = $oldEd
  }
}

function Remove-Commit {
  <#
  .SYNOPSIS
  Removes a commit from a branch by rewriting history.

  .DESCRIPTION
  Removes a commit from a given branch.

  - If the commit is HEAD, this uses `git reset --hard HEAD~1`.
  - If the commit is not HEAD, this uses `git rebase --onto <commit^> <commit> <branch>`
    which replays commits after the target commit onto its parent.

  This command rewrites history and may require force pushing.

  .PARAMETER CommitRef
  Commit-ish to remove (SHA, HEAD, etc.).

  .PARAMETER Branch
  Branch to remove the commit from. Defaults to the current branch.

  .PARAMETER Push
  If specified, pushes the rewritten branch to origin.

  .PARAMETER ForcePush
  If specified and -Push is set, uses --force-with-lease.

  .OUTPUTS
  System.String
  The branch name rewritten.
  #>

  [CmdletBinding(SupportsShouldProcess = $true)]
  [OutputType([string])]
  param(
    [Parameter(Position = 0, Mandatory = $true)]
    [ValidatePattern("^HEAD(~\d+)?$|^[0-9a-f]{7,40}$")]
    [string]$CommitRef,

    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]$Branch,

    [Parameter()]
    [switch]$Push,

    [Parameter()]
    [switch]$ForcePush
  )

  if (-not $Branch) {
    $Branch = (git rev-parse --abbrev-ref HEAD)
    if ($LASTEXITCODE -ne 0 -or -not $Branch) {
      throw "Failed to get current branch."
    }
    $Branch = $Branch.Trim()
  }

  if ($Branch -eq 'HEAD') {
    throw "You are in a detached HEAD state. Checkout a branch before calling Remove-Commit."
  }

  $commitHash = (git rev-parse $CommitRef)
  if ($LASTEXITCODE -ne 0 -or -not $commitHash) {
    throw "Failed to resolve commit reference '$CommitRef'."
  }
  $commitHash = $commitHash.Trim()

  # Ensure the commit is on the branch we are rewriting.
  git merge-base --is-ancestor $commitHash $Branch | Out-Null
  if ($LASTEXITCODE -ne 0) {
    throw "Commit $commitHash is not an ancestor of branch '$Branch'."
  }

  $branchHead = (git rev-parse $Branch).Trim()
  if ($branchHead -eq $commitHash) {
    if ($PSCmdlet.ShouldProcess($Branch, "Remove HEAD commit (reset --hard $Branch~1)")) {
      Invoke-Git -ErrorMessage "git reset --hard $Branch~1" reset --hard "$Branch~1"
    }
  }
  else {
    git rev-parse --verify "$commitHash^" 2>$null | Out-Null
    if ($LASTEXITCODE -ne 0) {
      throw "Cannot remove the initial commit via rebase."
    }

    if ($PSCmdlet.ShouldProcess($Branch, "Remove commit via rebase --onto")) {
      # Rebase the *branch ref* so we don't end up detached.
      Invoke-Git -ErrorMessage "git rebase --onto failed while removing $commitHash from $Branch" rebase --onto "$commitHash^" $commitHash $Branch
    }
  }

  if ($Push) {
    if ($ForcePush) {
      Invoke-Git -ErrorMessage "git push --force-with-lease origin $Branch" push --force-with-lease origin $Branch
    }
    else {
      Invoke-Git -ErrorMessage "git push origin $Branch" push origin $Branch
    }
  }

  return $Branch
}

function Move-Commit {
  <#
  .SYNOPSIS
  Moves (or copies) a commit from the current branch to another branch.

  .DESCRIPTION
  Applies a commit to a destination branch via cherry-pick.

  To avoid disrupting the caller's working directory, this function uses a temporary
  `git worktree` for the destination branch, so it does NOT need to checkout/switch
  branches in the current working tree.

  Optionally, the commit can be removed from the current branch (history rewrite).
  Removing a non-HEAD commit requires a rebase operation and therefore assumes the
  current branch contains the commit and that you are okay with rewriting history.

  .PARAMETER CommitRef
  Commit-ish to move/copy. Defaults to HEAD.

  .PARAMETER DestinationBranch
  The destination branch to receive the commit. Must exist locally or on origin.

  .PARAMETER RemoveFromSource
  If specified, removes the commit from the current branch after applying it to the destination.
  This rewrites history.

  .PARAMETER Push
  If specified, pushes the destination branch (and source branch if RemoveFromSource) to origin.

  .PARAMETER ForcePushSource
  If specified and RemoveFromSource is set, force-pushes the rewritten source branch.

  .PARAMETER AutoStash
  If specified, stashes uncommitted changes at the start and restores them at the end.
  Without AutoStash, the working tree must be clean.

  .OUTPUTS
  System.String
  The destination branch name.

  .NOTES
  This command can rewrite history when -RemoveFromSource is specified.
  Prefer using on local/unpublished branches (or be prepared to force push).
  #>

  [CmdletBinding(SupportsShouldProcess = $true)]
  [OutputType([string])]
  param(
    [Parameter(Position = 0)]
    [ValidatePattern("^HEAD(~\d+)?$|^[0-9a-f]{7,40}$")]
    [string]$CommitRef = "HEAD",

    [Parameter(Position = 1, Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string]$DestinationBranch,

    [Parameter()]
    [switch]$RemoveFromSource,

    [Parameter()]
    [switch]$Push,

    [Parameter()]
    [switch]$ForcePushSource,

    [Parameter()]
    [switch]$AutoStash
  )

  $repoRoot = (git rev-parse --show-toplevel)
  if ($LASTEXITCODE -ne 0 -or -not $repoRoot) {
    throw "Move-Commit must be run inside a git repository."
  }

  $stashed = $false
  $stashName = $null
  $destWorktreePath = $null

  try {
    # Ensure worktree safety. A dirty tree can make destructive operations risky.
    $status = (git status --porcelain)
    if ($LASTEXITCODE -ne 0) {
      throw "Failed to determine git status."
    }
    if (-not [string]::IsNullOrWhiteSpace($status)) {
      if (-not $AutoStash) {
        throw "Uncommitted changes detected. Re-run with -AutoStash, or commit/stash your changes before calling Move-Commit."
      }

      $stashName = "gitsplit-move-commit-$(Get-Date -Format 'yyyyMMddHHmmss')"
      Invoke-Git -ErrorMessage 'git stash push failed' stash push -u -m $stashName
      $stashed = $true
    }

    $currentBranch = (git rev-parse --abbrev-ref HEAD)
    if ($LASTEXITCODE -ne 0 -or -not $currentBranch) {
      throw "Failed to get current branch."
    }
    $currentBranch = $currentBranch.Trim()
    if ($currentBranch -eq 'HEAD') {
      throw "You are in a detached HEAD state. Checkout a branch before calling Move-Commit."
    }

    # Resolve commit hash.
    $commitHash = (git rev-parse $CommitRef)
    if ($LASTEXITCODE -ne 0 -or -not $commitHash) {
      throw "Failed to resolve commit reference '$CommitRef'."
    }
    $commitHash = $commitHash.Trim()

    # Verify destination branch exists locally or on origin.
    $branchExists = $true
    git show-ref --verify --quiet "refs/heads/$DestinationBranch" | Out-Null
    if ($LASTEXITCODE -ne 0) {
      $branchExists = $false
    }
    $remoteBranchExists = $true
    git show-ref --verify --quiet "refs/remotes/origin/$DestinationBranch" | Out-Null
    if ($LASTEXITCODE -ne 0) {
      $remoteBranchExists = $false
    }
    if (-not $branchExists -and -not $remoteBranchExists) {
      throw "Destination branch '$DestinationBranch' does not exist locally or on origin. Create it first."
    }

    # Create a temporary worktree for destination branch so we don't have to switch.
    $wtRoot = Join-Path $repoRoot '.gitsplit-worktrees'
    if (-not (Test-Path -LiteralPath $wtRoot)) {
      New-Item -Path $wtRoot -ItemType Directory -Force | Out-Null
    }
    $destWorktreePath = Join-Path $wtRoot ([guid]::NewGuid().ToString())

    if ($PSCmdlet.ShouldProcess("$DestinationBranch", "Cherry-pick $commitHash")) {
      if ($remoteBranchExists -and -not $branchExists) {
        # Create a local branch from origin in the worktree.
        Invoke-Git -ErrorMessage "git worktree add -b $DestinationBranch" worktree add -b $DestinationBranch $destWorktreePath "origin/$DestinationBranch"
      }
      else {
        Invoke-Git -ErrorMessage "git worktree add $DestinationBranch" worktree add $destWorktreePath $DestinationBranch
      }

      # Apply commit to destination.
      Invoke-Git -ErrorMessage "git -C <worktree> cherry-pick failed for $commitHash" -C $destWorktreePath cherry-pick $commitHash

      if ($Push) {
        Invoke-Git -ErrorMessage "git -C <worktree> push failed for $DestinationBranch" -C $destWorktreePath push -u origin $DestinationBranch
      }
    }

    if ($RemoveFromSource) {
      $null = Remove-Commit -CommitRef $commitHash -Branch $currentBranch -Push:$Push -ForcePush:$ForcePushSource
    }

    return $DestinationBranch
  }
  finally {
    # Best-effort cleanup: remove worktree.
    if ($destWorktreePath -and (Test-Path -LiteralPath $destWorktreePath)) {
      git worktree remove --force $destWorktreePath 2>$null | Out-Null
    }

    if ($stashed) {
      # Try to re-apply the stash created by this function.
      # IMPORTANT: do NOT pop/apply if we're mid-merge/rebase/cherry-pick.
      # Also, pop the *specific* stash we created (not simply the top of the stash stack).

      $gitDir = (git rev-parse --git-dir)
      if ($LASTEXITCODE -eq 0 -and $gitDir) {
        $gitDir = $gitDir.Trim()
        if (-not [System.IO.Path]::IsPathRooted($gitDir)) {
          $gitDir = Join-Path $repoRoot $gitDir
        }
      }

      $stashLine = $null
      try {
        $stashLine = (git stash list --format="%gd %s" | Where-Object { $_ -like "*${stashName}*" } | Select-Object -First 1)
      }
      catch {
        $stashLine = $null
      }

      if ($stashLine) {
        $stashRef = ($stashLine -split '\s+', 2)[0]

        $inProgress = $false
        if ($gitDir -and (Test-Path -LiteralPath $gitDir)) {
          $inProgress = (
            (Test-Path -LiteralPath (Join-Path $gitDir 'rebase-apply')) -or
            (Test-Path -LiteralPath (Join-Path $gitDir 'rebase-merge')) -or
            (Test-Path -LiteralPath (Join-Path $gitDir 'MERGE_HEAD')) -or
            (Test-Path -LiteralPath (Join-Path $gitDir 'CHERRY_PICK_HEAD')) -or
            (Test-Path -LiteralPath (Join-Path $gitDir 'REVERT_HEAD'))
          )
        }

        if ($inProgress) {
          Write-Error @(
            "Move-Commit created a stash ('$stashName' -> $stashRef) but will NOT restore it because git reports an in-progress operation (merge/rebase/cherry-pick/revert).",
            '',
            'How to proceed:',
            " 1) Inspect state: git status",
            " 2) Finish or abort operation: git rebase --continue | git rebase --abort | git merge --abort | git cherry-pick --abort | git revert --abort",
            " 3) Then restore your changes: git stash pop $stashRef",
            '',
            'How to undo the branch rewrite (if you used -RemoveFromSource):',
            " - Find the pre-rewrite commit in reflog: git reflog",
            " - Reset branch back to it: git reset --hard <sha>",
            " - If you pushed/force-pushed: git push --force-with-lease"
          ) -join [Environment]::NewLine
        }
        else {
          git stash pop $stashRef | Out-Null
        }
      }
    }
  }
}

function Get-CommitMessageFromChanges {
  <#
  .SYNOPSIS
  Generates a commit message suggestion from current repo changes.

  .DESCRIPTION
  This function is intentionally lightweight and dependency-free.
  - If there are no changes in the working tree or index, returns $null.
  - If there are changes but no Anthropic API key/token is configured, throws.

  NOTE: The full "AI-generated message" behavior is intentionally not implemented here.
  The module's tests currently validate only the no-changes and no-key guardrails.

  .PARAMETER DiffLevel
  Controls how much diff context would be used for generation (reserved for future use).
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $false)]
    [ValidateSet('None', 'Summary', 'Full')]
    [string]$DiffLevel = 'Summary'
  )

  # Detect changes (staged or unstaged). git diff --quiet returns exit code 1 when there are changes.
  git diff --quiet | Out-Null
  $hasUnstaged = ($LASTEXITCODE -ne 0)

  git diff --cached --quiet | Out-Null
  $hasStaged = ($LASTEXITCODE -ne 0)

  if (-not $hasUnstaged -and -not $hasStaged) {
    return $null
  }

  $key = $env:AnthropicKey
  if (-not $key) { $key = $env:ANTHROPIC_TOKEN }
  if ([string]::IsNullOrWhiteSpace($key)) {
    throw "Anthropic key is not set. Set env:AnthropicKey or env:ANTHROPIC_TOKEN."
  }

  # Placeholder: a deterministic fallback until an LLM-backed implementation is added.
  return "Update changes"
}

if ($env:CI) {
  Write-Host "Exporting all module members for CI environment."
  Export-ModuleMember *
}
else {
  # NOTE: The module manifest (GitSplit.psd1) also declares FunctionsToExport.
  # PowerShell effectively filters exports through BOTH lists, so keep them aligned
  # to avoid surprising "only the intersection" exports.
  Export-ModuleMember -Function @(
    'Split-Commit'
    'Add-Commit'
    'Remove-Commit'
    'Move-Commit'
    'Get-CommitMessageFromChanges'
  )
}