diff --git a/GitSplit.Tests.ps1 b/GitSplit.Tests.ps1 index daa1276..d52df6d 100644 --- a/GitSplit.Tests.ps1 +++ b/GitSplit.Tests.ps1 @@ -204,6 +204,7 @@ Import-Module '$escapedManifestPath' -Force 'Remove-Commit' 'Select-GitSplitPaths' 'Set-CommitOrder' + 'Split-ByPath' 'Split-Commit' 'Split-Hunk' 'Split-Patch' @@ -5598,4 +5599,451 @@ jobs: { Test-GitCommitIsAncestor -Ancestor 'abc123' -Descendant 'def456' } | Should -Throw '*Failed to determine*' } } + + Describe "Split-ByPath" { + # Helper: read a file's content from a given commit (avoids checkout). + function script:Get-FileAtCommit([string]$Commit, [string]$Path) { + return (git show "$Commit`:$Path" 2>$null) + } + + It "throws when not on a branch (detached HEAD)" { + Push-Location $script:TempRepoPath + try { + $head = (git rev-parse HEAD).Trim() + git checkout -q --detach $head + try { + { Split-ByPath -Path 'a.txt' -DestinationBranch 'dest' -BaseRef 'HEAD~1' } | + Should -Throw '*detached HEAD*' + } + finally { + git checkout -q main 2>$null + } + } + finally { + Pop-Location + } + } + + It "throws when BaseRef is not an ancestor of HEAD" { + Push-Location $script:TempRepoPath + try { + # Create a parentless (orphan) commit that shares HEAD~1's tree but is NOT in main's history. + $orphanTree = (git rev-parse 'HEAD~1^{tree}').Trim() + $orphan = (git commit-tree $orphanTree -m 'orphan').Trim() + { Split-ByPath -Path 'a.txt' -DestinationBranch 'dest' -BaseRef $orphan } | + Should -Throw '*not an ancestor of HEAD*' + } + finally { + Pop-Location + } + } + + It "throws when the destination branch already exists" { + Push-Location $script:TempRepoPath + try { + git branch exists-dest | Out-Null + { Split-ByPath -Path 'a.txt' -DestinationBranch 'exists-dest' -BaseRef 'HEAD~1' } | + Should -Throw "*Destination branch 'exists-dest' already exists*" + } + finally { + Pop-Location + } + } + + It "throws when the specified paths did not change in the range" { + Push-Location $script:TempRepoPath + try { + # a.txt was last modified in commit 2 (HEAD~1). b.txt changed in commit 3 (HEAD). + # Using BaseRef=HEAD~1, the range HEAD~1..HEAD only changed b.txt, so a.txt is unchanged. + { Split-ByPath -Path 'a.txt' -DestinationBranch 'dest' -BaseRef 'HEAD~1' } | + Should -Throw '*No changes to the specified paths*' + } + finally { + Pop-Location + } + } + + It "throws when BaseRef equals HEAD (nothing to split)" { + Push-Location $script:TempRepoPath + try { + { Split-ByPath -Path 'a.txt' -DestinationBranch 'dest' -BaseRef 'HEAD' } | + Should -Throw '*nothing to split*' + } + finally { + Pop-Location + } + } + + It "extracts a path into a stacked destination and removes it from the source (squash, destructive default)" { + Push-Location $script:TempRepoPath + try { + $sourceBranch = (git rev-parse --abbrev-ref HEAD).Trim() + $originalHead = (git rev-parse HEAD).Trim() + $baseCommit = (git rev-parse HEAD~2).Trim() # "Add a.txt and b.txt" + $baseA = (git show "$baseCommit`:a.txt" 2>$null) -join "`n" + $headB = (git show "$originalHead`:b.txt" 2>$null) -join "`n" + + $result = Split-ByPath -Path 'a.txt' -DestinationBranch 'sbp-stacked' -BaseRef 'HEAD~2' + $result | Should -Be 'sbp-stacked' + + # Still on the source branch. + (git rev-parse --abbrev-ref HEAD).Trim() | Should -Be $sourceBranch + + # Destination is stacked on the rewritten source tip. + $newSourceTip = (git rev-parse $sourceBranch).Trim() + (git rev-parse 'sbp-stacked^').Trim() | Should -Be $newSourceTip + + # The destination diff vs the source is exactly the extracted path. + (git diff --name-only $sourceBranch 'sbp-stacked') | Should -Be 'a.txt' + + # Source no longer contains a.txt's change (reverted to base), keeps b.txt's change. + $sourceA = (git show "$newSourceTip`:a.txt" 2>$null) -join "`n" + $sourceA | Should -Be $baseA + $sourceB = (git show "$newSourceTip`:b.txt" 2>$null) -join "`n" + $sourceB | Should -Be $headB + + # Source collapsed to a single commit on top of the base. + (git rev-parse "$sourceBranch^").Trim() | Should -Be $baseCommit + } + finally { + Pop-Location + } + } + + It "extracts a path into a flat destination and leaves the source untouched (squash, copy mode)" { + Push-Location $script:TempRepoPath + try { + $sourceBranch = (git rev-parse --abbrev-ref HEAD).Trim() + $originalHead = (git rev-parse HEAD).Trim() + $baseCommit = (git rev-parse HEAD~2).Trim() + + $result = Split-ByPath -Path 'a.txt' -DestinationBranch 'sbp-copy' -BaseRef 'HEAD~2' -RemoveFromSource:$false + $result | Should -Be 'sbp-copy' + + # Source is completely untouched. + (git rev-parse $sourceBranch).Trim() | Should -Be $originalHead + (git rev-parse --abbrev-ref HEAD).Trim() | Should -Be $sourceBranch + + # Flat destination rooted on the base commit. + (git rev-parse 'sbp-copy^').Trim() | Should -Be $baseCommit + (git diff --name-only $baseCommit 'sbp-copy') | Should -Be 'a.txt' + + # Destination carries a.txt's net change; b.txt stays at base version in the destination. + $destB = (git show "sbp-copy`:b.txt" 2>$null) -join "`n" + $baseB = (git show "$baseCommit`:b.txt" 2>$null) -join "`n" + $destB | Should -Be $baseB + } + finally { + Pop-Location + } + } + + It "extracts a newly-added file onto an explicit destination base (squash, flat)" { + Push-Location $script:TempRepoPath + try { + # Add c.txt, then modify a.txt, so the range has two commits to collapse. + 'c-line-1' | Set-Content -Path 'c.txt' + git add c.txt | Out-Null + git commit -m 'Add c.txt' | Out-Null + + 'a-line-1' | Set-Content -Path 'a.txt' + git add a.txt | Out-Null + git commit -m 'Modify a.txt again' | Out-Null + + $baseCommit = (git rev-parse HEAD~2).Trim() # before c.txt was added + $destBase = (git rev-parse HEAD~3).Trim() # "Initial" (no c.txt, no a.txt edits from here) + + $result = Split-ByPath -Path 'c.txt' -DestinationBranch 'sbp-flatbase' -BaseRef 'HEAD~2' -DestinationBase $destBase + $result | Should -Be 'sbp-flatbase' + + # Destination rooted on the explicit base, carries c.txt as an add. + (git rev-parse 'sbp-flatbase^').Trim() | Should -Be $destBase + (git diff --name-only $destBase 'sbp-flatbase') | Should -Be 'c.txt' + (git show "sbp-flatbase`:c.txt" 2>$null) | Should -Be 'c-line-1' + } + finally { + Pop-Location + } + } + + It "extracts a deletion: destination carries the deletion, source restores the file (squash, destructive)" { + Push-Location $script:TempRepoPath + try { + # Add d.txt, then delete it (plus an unrelated a.txt edit) in the range. + 'd-line-1' | Set-Content -Path 'd.txt' + git add d.txt | Out-Null + git commit -m 'Add d.txt' | Out-Null + $dBase = (git rev-parse HEAD).Trim() + + git rm -q d.txt + 'a-line-1' | Set-Content -Path 'a.txt' + git add a.txt | Out-Null + git commit -m 'Delete d.txt and modify a.txt' | Out-Null + + $result = Split-ByPath -Path 'd.txt' -DestinationBranch 'sbp-delete' -BaseRef $dBase + $result | Should -Be 'sbp-delete' + + $sourceBranch = (git rev-parse --abbrev-ref HEAD).Trim() + $newSourceTip = (git rev-parse $sourceBranch).Trim() + + # Source restored d.txt. + git cat-file -e "$newSourceTip`:d.txt" 2>$null + $LASTEXITCODE | Should -Be 0 + # Destination deleted d.txt. + git cat-file -e "sbp-delete`:d.txt" 2>$null + $LASTEXITCODE | Should -Not -Be 0 + # The only difference between source and destination is d.txt. + (git diff --name-only $sourceBranch 'sbp-delete') | Should -Be 'd.txt' + } + finally { + Pop-Location + } + } + + It "extracts multiple paths together into one destination (squash, destructive)" { + Push-Location $script:TempRepoPath + try { + $sourceBranch = (git rev-parse --abbrev-ref HEAD).Trim() + $originalHead = (git rev-parse HEAD).Trim() + $baseCommit = (git rev-parse HEAD~2).Trim() + + $result = Split-ByPath -Path 'a.txt','b.txt' -DestinationBranch 'sbp-multi' -BaseRef 'HEAD~2' + $result | Should -Be 'sbp-multi' + + $newSourceTip = (git rev-parse $sourceBranch).Trim() + # Both paths extracted: source tree == base tree (all changes moved to destination). + (git diff --name-only $baseCommit $sourceBranch) | Should -BeNullOrEmpty + # Destination diff vs source is exactly both paths. + $diffNames = (git diff --name-only $sourceBranch 'sbp-multi') | Sort-Object + $diffNames | Should -Be @('a.txt', 'b.txt') + } + finally { + Pop-Location + } + } + + It "can write a reviewable script without executing it" { + $scriptPath = $null + Push-Location $script:TempRepoPath + try { + $headCommit = (git rev-parse HEAD).Trim() + $baseCommit = (git rev-parse HEAD~2).Trim() + + $scriptPath = Join-Path ([System.IO.Path]::GetTempPath()) ("split-bypath-" + (New-Guid) + ".ps1") + $writtenPath = Split-ByPath -Path 'a.txt' -DestinationBranch 'sbp-scripted' -BaseRef 'HEAD~2' -OutputScriptPath $scriptPath + $writtenPath | Should -Be $scriptPath + Test-Path $scriptPath | Should -BeTrue + + $scriptText = Get-Content -Path $scriptPath -Raw + $scriptText | Should -Match ([regex]::Escape('# Generated by GitSplit: Split-ByPath')) + $scriptText | Should -Match ([regex]::Escape("`$expectedHead = '$headCommit'")) + $scriptText | Should -Match ([regex]::Escape("`$baseCommit = '$baseCommit'")) + $scriptText | Should -Match ([regex]::Escape("`$destinationBranch = 'sbp-scripted'")) + # Commit-from-index technique markers. + $scriptText | Should -Match ([regex]::Escape('reset --soft $baseCommit')) + $scriptText | Should -Match ([regex]::Escape('reset HEAD -- @paths')) + # Destructive-by-default review-bot note is present in the source (not the script), but the + # script should reflect the destructive rewrite. + $scriptText | Should -Match ([regex]::Escape('update-ref "refs/heads/$expectedBranch" $sourceTip')) + + # Nothing executed: destination branch must not exist, HEAD unchanged. + (git show-ref --verify --quiet 'refs/heads/sbp-scripted') | Should -BeFalse + (git rev-parse HEAD).Trim() | Should -Be $headCommit + } + finally { + if ($scriptPath -and (Test-Path $scriptPath)) { + Remove-Item -Path $scriptPath -Force + } + Pop-Location + } + } + + It "stashes and restores uncommitted changes when -AutoStash is used" { + Push-Location $script:TempRepoPath + try { + $sourceBranch = (git rev-parse --abbrev-ref HEAD).Trim() + $originalHead = (git rev-parse HEAD).Trim() + + # Dirty working tree change to b.txt (which is NOT being extracted). + 'dirty-b-line' | Add-Content -Path 'b.txt' + + $result = Split-ByPath -Path 'a.txt' -DestinationBranch 'sbp-stash' -BaseRef 'HEAD~2' -AutoStash + $result | Should -Be 'sbp-stash' + + # The dirty change was restored. + (Get-Content -Path 'b.txt' -Tail 1) | Should -Be 'dirty-b-line' + # And the split happened. + (git diff --name-only $sourceBranch 'sbp-stash') | Should -Be 'a.txt' + # No stash left behind. + (git stash list) | Should -BeNullOrEmpty + } + finally { + Pop-Location + } + } + + It "refuses to run with a dirty working tree when -AutoStash is not set" { + Push-Location $script:TempRepoPath + try { + 'dirty-line' | Add-Content -Path 'b.txt' + { Split-ByPath -Path 'a.txt' -DestinationBranch 'sbp-noautostash' -BaseRef 'HEAD~2' } | + Should -Throw '*Uncommitted changes detected*' + # Destination not created. + (git show-ref --verify --quiet 'refs/heads/sbp-noautostash') | Should -BeFalse + } + finally { + Pop-Location + } + } + + It "applies only the BaseRef..HEAD change onto an older destination base (squash, flat)" { + Push-Location $script:TempRepoPath + try { + # Ten lines, so the edit before BaseRef and the edit inside the range sit far apart. + $lines = @(1..10 | ForEach-Object { "f-line-$_" }) + $lines | Set-Content -Path 'f.txt' + git add f.txt | Out-Null + git commit -m 'Add f.txt' | Out-Null + $destBase = (git rev-parse HEAD).Trim() + + $lines[0] = 'f-line-1 (edited before BaseRef)' + $lines | Set-Content -Path 'f.txt' + git commit -am 'Edit f.txt line 1' | Out-Null + $baseRef = (git rev-parse HEAD).Trim() + + $lines[9] = 'f-line-10 (edited in range)' + $lines | Set-Content -Path 'f.txt' + git commit -am 'Edit f.txt line 10' | Out-Null + + $result = Split-ByPath -Path 'f.txt' -DestinationBranch 'sbp-olderbase' -BaseRef $baseRef -DestinationBase $destBase -RemoveFromSource:$false + $result | Should -Be 'sbp-olderbase' + + # The destination carries the range's line-10 edit only; line 1 keeps the destination base's text. + (git rev-parse 'sbp-olderbase^').Trim() | Should -Be $destBase + $destLines = @(Get-FileAtCommit 'sbp-olderbase' 'f.txt') + $destLines[0] | Should -Be 'f-line-1' + $destLines[9] | Should -Be 'f-line-10 (edited in range)' + } + finally { + Pop-Location + } + } + + It "throws without moving any ref when the change does not apply onto the destination base" { + Push-Location $script:TempRepoPath + try { + $sourceBranch = (git rev-parse --abbrev-ref HEAD).Trim() + $originalHead = (git rev-parse HEAD).Trim() + # "Initial" has no a.txt, but BaseRef..HEAD edits it: there is nothing to apply the edit to. + $initial = (git rev-parse HEAD~3).Trim() + + { Split-ByPath -Path 'a.txt' -DestinationBranch 'sbp-noapply' -BaseRef 'HEAD~2' -DestinationBase $initial } | + Should -Throw '*do not apply cleanly*' + + git show-ref --verify --quiet 'refs/heads/sbp-noapply' + $LASTEXITCODE | Should -Not -Be 0 + (git rev-parse $sourceBranch).Trim() | Should -Be $originalHead + } + finally { + Pop-Location + } + } + + It "prints the source's old and new tip on completion (squash)" { + Push-Location $script:TempRepoPath + try { + $sourceBranch = (git rev-parse --abbrev-ref HEAD).Trim() + $originalHead = (git rev-parse HEAD).Trim() + + $output = Split-ByPath -Path 'a.txt' -DestinationBranch 'sbp-output' -BaseRef 'HEAD~2' 6>&1 | Out-String + + $newSourceTip = (git rev-parse $sourceBranch).Trim() + $output | Should -Match ([regex]::Escape("$sourceBranch $originalHead -> $newSourceTip")) + $output | Should -Match ([regex]::Escape("was squashed into $newSourceTip")) + } + finally { + Pop-Location + } + } + + Context "preserve mode (-Squash:`$false)" { + It "rewrites only BaseRef..HEAD, keeping commit structure and restoring the paths to BaseRef (destructive)" { + Push-Location $script:TempRepoPath + try { + $sourceBranch = (git rev-parse --abbrev-ref HEAD).Trim() + $originalHead = (git rev-parse HEAD).Trim() + $baseCommit = (git rev-parse HEAD~2).Trim() + # NOTE: the ".." revision range MUST be quoted -- unquoted, PowerShell parses it as its range + # operator between the sha and the bareword HEAD, mangling the argument. + $originalCommitCount = [int](git rev-list --count "$baseCommit..HEAD").Trim() + + $result = Split-ByPath -Path 'a.txt' -DestinationBranch 'sbp-preserve' -BaseRef 'HEAD~2' -Squash:$false + $result | Should -Be 'sbp-preserve' + + $newSourceTip = (git rev-parse $sourceBranch).Trim() + $newSourceTip | Should -Not -Be $originalHead + # Only BaseRef..HEAD is rewritten: the base itself (where a.txt already existed) stays in the + # history, and the in-range commit count is unchanged. + (git rev-parse "$newSourceTip~$originalCommitCount").Trim() | Should -Be $baseCommit + $newCommitCount = [int](git rev-list --count "$baseCommit..$newSourceTip").Trim() + $newCommitCount | Should -Be $originalCommitCount + # a.txt is back at its BaseRef content on the source (not deleted); b.txt keeps its edits. + (git diff --name-only $baseCommit $newSourceTip) | Should -Be 'b.txt' + (Get-FileAtCommit $newSourceTip 'a.txt') | Should -Be (Get-FileAtCommit $baseCommit 'a.txt') + # Destination is stacked on the rewritten source and carries only a.txt's change. + (git rev-parse 'sbp-preserve^').Trim() | Should -Be $newSourceTip + (git diff --name-only $newSourceTip 'sbp-preserve') | Should -Be 'a.txt' + (Get-FileAtCommit 'sbp-preserve' 'a.txt') | Should -Be (Get-FileAtCommit $originalHead 'a.txt') + } + finally { + Pop-Location + } + } + + It "keeps commits that never touched the paths and drops commits that only changed them (preserve)" { + Push-Location $script:TempRepoPath + try { + $sourceBranch = (git rev-parse --abbrev-ref HEAD).Trim() + 'c-line-1' | Set-Content -Path 'c.txt' + git add c.txt | Out-Null + git commit -m 'Add c.txt' | Out-Null + $untouched = (git rev-parse HEAD).Trim() + 'd-line-1' | Set-Content -Path 'd.txt' + git add d.txt | Out-Null + git commit -m 'Add d.txt' | Out-Null + $onlyPath = (git rev-parse HEAD).Trim() + + $output = Split-ByPath -Path 'd.txt' -DestinationBranch 'sbp-preserve-drop' -BaseRef 'HEAD~2' -Squash:$false 6>&1 | Out-String + + # "Add c.txt" never touched d.txt, so it keeps its SHA; "Add d.txt" only added d.txt and is dropped. + (git rev-parse $sourceBranch).Trim() | Should -Be $untouched + $output | Should -Match ([regex]::Escape("$untouched -> $untouched")) + $output | Should -Match ([regex]::Escape("$onlyPath -> dropped")) + (git rev-parse 'sbp-preserve-drop^').Trim() | Should -Be $untouched + (git diff --name-only $untouched 'sbp-preserve-drop') | Should -Be 'd.txt' + } + finally { + Pop-Location + } + } + + It "leaves the source untouched in copy mode (preserve)" { + Push-Location $script:TempRepoPath + try { + $sourceBranch = (git rev-parse --abbrev-ref HEAD).Trim() + $originalHead = (git rev-parse HEAD).Trim() + + $result = Split-ByPath -Path 'a.txt' -DestinationBranch 'sbp-preserve-copy' -BaseRef 'HEAD~2' -Squash:$false -RemoveFromSource:$false + $result | Should -Be 'sbp-preserve-copy' + + (git rev-parse $sourceBranch).Trim() | Should -Be $originalHead + $baseCommit = (git rev-parse HEAD~2).Trim() + (git diff --name-only $baseCommit 'sbp-preserve-copy') | Should -Be 'a.txt' + } + finally { + Pop-Location + } + } + } + } } diff --git a/GitSplit.psd1 b/GitSplit.psd1 index 0686f2c..9bafd9f 100644 --- a/GitSplit.psd1 +++ b/GitSplit.psd1 @@ -24,6 +24,7 @@ 'Add-Commit' 'Remove-Commit' 'Move-Commit' + 'Split-ByPath' 'Set-CommitOrder' 'Invoke-GitSplitAbsorb' 'Get-CommitMessageFromChanges' diff --git a/GitSplit.psm1 b/GitSplit.psm1 index 5e014ea..80173a7 100644 --- a/GitSplit.psm1 +++ b/GitSplit.psm1 @@ -4597,6 +4597,740 @@ function Get-CommitMessageFromChanges { return "Update changes" } +################################################################################ +# Split-ByPath +# +# Destructive-by-default note for reviewers / static analyzers: +# ---------------------------------------------------------------------------- +# Split-ByPath and Move-Commit are INTENTIONALLY destructive by default +# (-RemoveFromSource defaults to $true). This mirrors the semantics of a +# filesystem `mv`: "split" and "move" carry an inherent removal, and a +# non-destructive copy is the opt-in (-RemoveFromSource:$false), not the +# default. This is NOT a defect to be flagged. These cmdlets emit reviewable +# git scripts (via -OutputScriptPath) whose *purpose* is the destructive +# operation (history rewrite of the source branch); every git reset / commit / +# update-ref / push --force-with-lease is present as plain text in the +# generated script for a human to read before execution. Defaulting to +# non-destructive would make the verbs useless by default (a "split" that +# leaves the paths in the source is a copy, not a split). Same principle as +# `git mv` / `mv` being destructive by default. +################################################################################ + +function New-SplitByPathPlan { + <# + .SYNOPSIS + Builds the execution plan for Split-ByPath (extract paths across BaseRef..HEAD into a separate branch). + + .DESCRIPTION + Range-aware counterpart to Split-Commit / Move-Commit. Extracts the net change to a set of paths + across the BaseRef..HEAD range into a destination branch, and (destructive by default) rewrites the + current branch so it no longer contains those paths' changes. + + The tip is always the current branch HEAD (no -TipRef): the verb's contract is "I'm on my feature + branch; extract these paths into a separate PR," and -RemoveFromSource rewrites *the branch you're + on*. A free tip would make the destructive default ambiguous about which branch it destroys. + + Two modes: + - -Squash (default): pure git. `git reset --soft BaseRef` stages the entire range, then commits + the paths and the remainder from the index. Committing from the index (not reconstructing via + `git checkout`) correctly handles adds, modifies, AND deletions uniformly -- no git rm + special-case. The source collapses to ONE squashed commit. + - -Squash:$false (preserve): replays each BaseRef..HEAD commit with the paths held at their + BaseRef state (pure git: a temporary index plus commit-tree). Keeps the source's commit + structure minus the paths' changes; commits before BaseRef keep their SHAs. + + Stacked vs flat destination (default stacked when destructive): + - Stacked (default, -RemoveFromSource): destination parented on the rewritten source tip; its PR + base is the source branch so the diff shows only the extracted paths. + - Flat (-DestinationBase , or copy mode): destination parented on -DestinationBase (or + BaseRef) as an independent sibling. + + The destination commit applies the paths' net BaseRef..HEAD change to its parent as a 3-way patch, + never the paths' whole HEAD snapshot, which would also carry edits made between an older + -DestinationBase and BaseRef. If the change does not apply cleanly, the plan throws before any ref + moves. + + This builder returns a New-GitPlan of Comment/Literal steps (the same plan/execute model as + Move-Commit), so the plan both renders to a reviewable script (Write-GitScript) and executes + (Invoke-GitPlan). + + .NOTES + History rewrite changes commit SHAs (squash: the whole range collapses to one new SHA; preserve: + every commit from first-path-touch onward is re-hashed). SHAs cited in PR review threads become + dangling on GitHub after force-push. On completion the plan prints the source branch's old and new + tip and, in preserve mode, each rewritten commit's old -> new SHA (or that it was dropped); + automatic review-thread SHA migration is out of scope. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string[]]$Path, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$DestinationBranch, + + [Parameter()] + [string]$BaseRef, + + [Parameter()] + [string]$DestinationBase, + + [Parameter()] + [switch]$Squash, + + [Parameter()] + [switch]$RemoveFromSource, + + [Parameter()] + [string]$SourceMessage, + + [Parameter()] + [string]$DestinationMessage, + + [Parameter()] + [switch]$Push, + + [Parameter()] + [switch]$ForcePushSource, + + [Parameter()] + [switch]$AutoStash, + + [Parameter()] + [switch]$KeepEmpty + ) + + # --- effective defaults for switch params that default to $true --- + $squash = if ($PSBoundParameters.ContainsKey('Squash')) { [bool]$Squash } else { $true } + $removeFromSource = if ($PSBoundParameters.ContainsKey('RemoveFromSource')) { [bool]$RemoveFromSource } else { $true } + + $repoRoot = Get-GitRepoRoot + $currentBranch = Get-GitCurrentBranch + if ($currentBranch -eq 'HEAD') { + throw "You are in a detached HEAD state. Checkout a branch before calling Split-ByPath (the tip is always the current branch HEAD)." + } + $currentHead = Resolve-GitCommit -Ref 'HEAD' -ErrorMessage 'Failed to resolve HEAD.' + + # --- resolve BaseRef (default: merge-base(HEAD, origin/HEAD)) --- + if ([string]::IsNullOrWhiteSpace($BaseRef)) { + $originHeadQuery = Invoke-GitQuery -AllowFailure -GitArgs @('symbolic-ref', 'refs/remotes/origin/HEAD') + $originHeadRef = $originHeadQuery.Output.Trim() + if ([string]::IsNullOrWhiteSpace($originHeadRef)) { + throw "Split-ByPath could not determine the default branch (origin/HEAD is unset). Specify -BaseRef explicitly, or run: git remote set-head origin " + } + $trunk = $originHeadRef -replace '^refs/remotes/origin/', '' + $mbQuery = Invoke-GitQuery -AllowFailure -GitArgs @('merge-base', 'HEAD', "origin/$trunk") + $BaseRef = $mbQuery.Output.Trim() + if ([string]::IsNullOrWhiteSpace($BaseRef)) { + throw "Failed to compute merge-base(HEAD, origin/$trunk). Specify -BaseRef explicitly." + } + } + $baseCommit = Resolve-GitCommit -Ref $BaseRef -ErrorMessage "Base reference '$BaseRef' is not valid." + + # --- range guards --- + if ($baseCommit -eq $currentHead) { + throw "BaseRef and HEAD resolve to the same commit ($baseCommit); nothing to split." + } + if (-not (Test-GitCommitIsAncestor -Ancestor $baseCommit -Descendant $currentHead)) { + throw "BaseRef '$BaseRef' ($baseCommit) is not an ancestor of HEAD ($currentHead)." + } + + # --- destination branch must not exist (a split creates a new PR branch; clobbering would be unsafe) --- + # Checked before the path-diff check so an obviously wrong destination name fails fast. + $destExistsLocal = Test-GitRefExists -Ref "refs/heads/$DestinationBranch" + $destExistsRemote = Test-GitRefExists -Ref "refs/remotes/origin/$DestinationBranch" + if ($destExistsLocal -or $destExistsRemote) { + $hints = @(" git branch -D $DestinationBranch") + if ($destExistsRemote) { + $hints += " git push origin --delete $DestinationBranch" + } + throw (@( + "Destination branch '$DestinationBranch' already exists locally or on origin." + "A split creates a new branch; delete it first or choose a different name:" + ) + $hints) -join [Environment]::NewLine + } + + # --- normalize paths to repo-relative --- + $normalizedPaths = @($Path | ForEach-Object { ConvertTo-GitSplitRepoRelativePath -Path $_ -RepoRoot $repoRoot } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique) + if ($normalizedPaths.Count -eq 0) { + throw "No valid paths to extract after normalization." + } + + # --- verify the paths actually changed in the range --- + $diffArgs = @('diff', '--name-only', "$baseCommit..$currentHead", '--') + $normalizedPaths + $diffQuery = Invoke-GitQuery -AllowFailure -GitArgs $diffArgs + $changedPaths = @($diffQuery.Lines | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($changedPaths.Count -eq 0) { + throw "No changes to the specified paths in $baseCommit..$currentHead; nothing to extract." + } + + # --- resolve destination base --- + $providedDestinationBase = -not [string]::IsNullOrWhiteSpace($DestinationBase) + $destinationBaseCommit = $null + if ($providedDestinationBase) { + $destinationBaseCommit = Resolve-GitCommit -Ref $DestinationBase -ErrorMessage "Destination base reference '$DestinationBase' is not valid." + if ($baseCommit -ne $destinationBaseCommit -and -not (Test-GitCommitIsAncestor -Ancestor $destinationBaseCommit -Descendant $currentHead)) { + throw "DestinationBase '$DestinationBase' ($destinationBaseCommit) must be either BaseRef or an ancestor of HEAD." + } + } + # Stacked (dest on rewritten source tip) is the default ONLY for the destructive case. + # For copy mode (RemoveFromSource:$false) the source keeps the paths, so a dest stacked on the + # source would show an empty diff -> default copy mode to flat (dest on BaseRef). + $stacked = $false + if (-not $providedDestinationBase) { + if ($removeFromSource) { + $stacked = $true # dest parent resolved to $sourceTip in-script + } + else { + $destinationBaseCommit = $baseCommit # flat on BaseRef + } + } + + # --- defaults for commit messages --- + if ([string]::IsNullOrWhiteSpace($SourceMessage)) { + $SourceMessage = "Split: extract paths into $DestinationBranch" + } + if ([string]::IsNullOrWhiteSpace($DestinationMessage)) { + $pathList = ($normalizedPaths -join ', ') + if ($pathList.Length -gt 80) { $pathList = $pathList.Substring(0, 77) + '...' } + $DestinationMessage = "Extract: $pathList" + } + + $plannedWorktreePath = New-GitSplitWorktreePath -RepoRoot $repoRoot + $plannedStashName = New-GitSplitStashName -Operation 'split-bypath' + $plannedScratchPath = New-GitSplitTempDirectoryPath -Prefix 'gitsplit-splitbypath' + $plannedDisabledHooksPath = New-GitSplitTempDirectoryPath -Prefix 'gitsplit-hooks' + + # =========================================================================== + # Build plan steps + # =========================================================================== + $steps = @() + $steps += New-GitStep -Kind Comment -Lines @( + 'Split-ByPath execution plan.', + 'Discovery-time values are frozen below; runtime guards ensure the repository has not drifted.', + 'Destructive by default (-RemoveFromSource): the source branch is rewritten to drop the extracted paths, like `mv`.' + ) + + # --- frozen values --- + $frozenLines = @( + '$expectedRepoRoot = ' + (ConvertTo-PowerShellStringLiteral $repoRoot) + '$expectedBranch = ' + (ConvertTo-PowerShellStringLiteral $currentBranch) + '$expectedHead = ' + (ConvertTo-PowerShellStringLiteral $currentHead) + '$baseCommit = ' + (ConvertTo-PowerShellStringLiteral $baseCommit) + '$destinationBranch = ' + (ConvertTo-PowerShellStringLiteral $DestinationBranch) + '$paths = @(' + (($normalizedPaths | ForEach-Object { ConvertTo-PowerShellStringLiteral $_ }) -join ', ') + ')' + '$squash = ' + $(if ($squash) { '$true' } else { '$false' }) + '$removeFromSource = ' + $(if ($removeFromSource) { '$true' } else { '$false' }) + '$stacked = ' + $(if ($stacked) { '$true' } else { '$false' }) + '$push = ' + $(if ($Push) { '$true' } else { '$false' }) + '$forcePushSource = ' + $(if ($ForcePushSource) { '$true' } else { '$false' }) + '$autoStash = ' + $(if ($AutoStash) { '$true' } else { '$false' }) + '$keepEmpty = ' + $(if ($KeepEmpty) { '$true' } else { '$false' }) + '$plannedStashName = ' + (ConvertTo-PowerShellStringLiteral $plannedStashName) + '$worktreePath = ' + (ConvertTo-PowerShellStringLiteral $plannedWorktreePath) + '$scratchPath = ' + (ConvertTo-PowerShellStringLiteral $plannedScratchPath) + '$disabledHooksPath = ' + (ConvertTo-PowerShellStringLiteral $plannedDisabledHooksPath) + '$stashed = $false' + '$stashName = $null' + '$worktreeCreated = $false' + '$scratchCreated = $false' + '$succeeded = $false' + ) + $frozenLines += ConvertTo-PowerShellHereStringLines -AssignmentPrefix '$sourceMessage = ' -Value $SourceMessage + $frozenLines += ConvertTo-PowerShellHereStringLines -AssignmentPrefix '$destinationMessage = ' -Value $DestinationMessage + if ($providedDestinationBase) { + $frozenLines += @( + '$destinationBaseCommit = ' + (ConvertTo-PowerShellStringLiteral $destinationBaseCommit) + ) + } + else { + $frozenLines += @('$destinationBaseCommit = $null') + } + $steps += New-GitStep -Kind Literal -Lines $frozenLines + + # --- runtime drift guards --- + $guardLines = @( + '$repoRoot = (& git rev-parse --show-toplevel).Trim()' + 'if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoRoot)) { throw "Split-ByPath must be run inside a git repository." }' + 'if ($repoRoot -ne $expectedRepoRoot) { throw "This script was generated for repo root ''$expectedRepoRoot'' but is running in ''$repoRoot''." }' + '$currentBranch = (& git rev-parse --abbrev-ref HEAD).Trim()' + 'if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($currentBranch)) { throw "Failed to get current branch." }' + 'if ($currentBranch -ne $expectedBranch) { throw "This script expected branch ''$expectedBranch'' but found ''$currentBranch''." }' + '$currentHead = (& git rev-parse HEAD).Trim()' + 'if ($LASTEXITCODE -ne 0 -or $currentHead -notmatch ''^[0-9a-f]{40}$'') { throw "Failed to resolve HEAD." }' + 'if ($currentHead -ne $expectedHead) { throw "This script expected HEAD ''$expectedHead'' but found ''$currentHead''. Re-run Split-ByPath to regenerate the plan." }' + # destination must still not exist + '& git show-ref --verify --quiet "refs/heads/$destinationBranch"' + 'if ($LASTEXITCODE -eq 0) { throw "Destination branch ''$destinationBranch'' now exists locally; refusing to clobber." }' + '& git show-ref --verify --quiet "refs/remotes/origin/$destinationBranch"' + 'if ($LASTEXITCODE -eq 0) { throw "Destination branch ''$destinationBranch'' now exists on origin; refusing to clobber." }' + # working tree cleanliness / autostash + '$status = @(& git status --porcelain)' + 'if ($LASTEXITCODE -ne 0) { throw "Failed to determine git status." }' + '$untrackedFiles = @($status | Where-Object { $_ -match "^\?\? " })' + '$modifiedFiles = @($status | Where-Object { $_ -notmatch "^\?\? " })' + 'if ($modifiedFiles.Count -gt 0) {' + ' if (-not $autoStash) {' + ' $fileList = ($modifiedFiles | ForEach-Object { $_.Substring(3) }) -join ", "' + ' throw "Uncommitted changes detected in: $fileList. Re-run with -AutoStash, or commit/stash your changes before running this script."' + ' }' + ' $stashName = $plannedStashName' + ' & git stash push -u -m $stashName 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git stash push failed" }' + ' $stashed = $true' + '}' + 'elseif ($untrackedFiles.Count -gt 0 -and $autoStash) {' + ' $stashName = $plannedStashName' + ' & git stash push -u -m $stashName 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git stash push failed" }' + ' $stashed = $true' + '}' + 'elseif ($untrackedFiles.Count -gt 0) {' + ' Write-Warning "Untracked files present ($($untrackedFiles.Count)). They will not be affected by this operation."' + '}' + ) + $steps += New-GitStep -Kind Literal -Lines $guardLines + + # --- execution --- + $execLines = @( + '$longPathGitArgs = @()' + 'if ($env:OS -eq ''Windows_NT'') { $longPathGitArgs = @(''-c'', ''core.longpaths=true'') }' + '$sourceTip = $null' + '$destSha = $null' + '# old -> new SHA of every rewritten source commit (preserve mode), printed on completion.' + '$commitMap = [ordered]@{}' + '$droppedCommits = @{}' + '# The temporary-index steps set these variables; the finally block restores the caller''s values.' + '$gitEnvNames = @(''GIT_INDEX_FILE'', ''GIT_AUTHOR_NAME'', ''GIT_AUTHOR_EMAIL'', ''GIT_AUTHOR_DATE'', ''GIT_COMMITTER_NAME'', ''GIT_COMMITTER_EMAIL'', ''GIT_COMMITTER_DATE'')' + '$savedGitEnv = @{}' + 'foreach ($name in $gitEnvNames) { $savedGitEnv[$name] = [Environment]::GetEnvironmentVariable($name) }' + '# Removes the variable for $null: [Environment]::SetEnvironmentVariable would get "" from PowerShell' + '# for a $null value and leave an empty variable behind, which breaks git (e.g. GIT_INDEX_FILE="").' + 'function Set-GitSplitEnv([string]$Name, $Value) {' + ' if ($null -eq $Value) { Remove-Item -LiteralPath "Env:$Name" -ErrorAction SilentlyContinue }' + ' else { Set-Item -LiteralPath "Env:$Name" -Value $Value }' + '}' + 'if (Test-Path -LiteralPath $scratchPath) { throw "Planned scratch path ''$scratchPath'' already exists." }' + 'try {' + ' New-Item -Path $scratchPath -ItemType Directory -Force | Out-Null' + ' $scratchCreated = $true' + ' $tempIndexPath = Join-Path $scratchPath ''index''' + ' $patchPath = Join-Path $scratchPath ''paths.patch''' + ' $messagePath = Join-Path $scratchPath ''message.txt''' + ' $identityPath = Join-Path $scratchPath ''identity.txt''' + ) + + if ($squash) { + $execLines += @( + '' + ' # --- squash mode: the source collapses to ONE commit, built from the index in a temp worktree ---' + ' if (Test-Path -LiteralPath $worktreePath) { throw "Planned worktree path ''$worktreePath'' already exists." }' + ' if (-not (Test-Path -LiteralPath $disabledHooksPath)) { New-Item -Path $disabledHooksPath -ItemType Directory -Force | Out-Null }' + ' & git @longPathGitArgs worktree add --detach $worktreePath $expectedHead 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git worktree add --detach failed" }' + ' $worktreeCreated = $true' + '' + ' # Stage the entire BaseRef..HEAD change set (index == HEAD tree), then unstage the extract paths.' + ' & git @longPathGitArgs -C $worktreePath -c "core.hooksPath=$disabledHooksPath" reset --soft $baseCommit 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git reset --soft failed" }' + ' & git @longPathGitArgs -C $worktreePath reset HEAD -- @paths 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git reset HEAD -- failed" }' + '' + ' # Source commit: everything EXCEPT the extract paths (committed from the index).' + ' # If ALL changed paths are being extracted, the index matches the base (nothing staged) and the' + ' # source collapses straight onto the base with no commit of its own -- the split becomes a pure' + ' # "move everything to a new branch" (stacked and flat coincide on the base).' + ' $null = & git @longPathGitArgs -C $worktreePath diff --cached --quiet 2>&1' + ' $sourceHasChanges = ($LASTEXITCODE -ne 0)' + ' if ($sourceHasChanges) {' + ' & git @longPathGitArgs -C $worktreePath -c "core.hooksPath=$disabledHooksPath" commit -m $sourceMessage --quiet 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git commit (source) failed" }' + ' $sourceTip = (& git -C $worktreePath rev-parse HEAD).Trim()' + ' if ($LASTEXITCODE -ne 0 -or $sourceTip -notmatch ''^[0-9a-f]{40}$'') { throw "Failed to resolve source tip." }' + ' }' + ' else {' + ' $sourceTip = $baseCommit' + ' }' + ) + } + else { + $execLines += @( + '' + ' # --- preserve mode: replay BaseRef..HEAD with the extracted paths held at their BaseRef state ---' + ' # Each commit''s tree is rebuilt in a temporary index (read-tree, then reset the paths to BaseRef)' + ' # and re-committed with its original author, committer, and message. Pure git: commits before' + ' # BaseRef keep their SHAs, unlike git filter-repo, which rewrites (and strips signatures from)' + ' # every commit that ever touched a path. Copy mode skips the rewrite; the source stays as is.' + ' if ($removeFromSource) {' + ' $revLines = @(& git -C $repoRoot rev-list --reverse --topo-order --parents "$baseCommit..$expectedHead")' + ' if ($LASTEXITCODE -ne 0) { throw "git rev-list $baseCommit..$expectedHead failed" }' + ' $identityNames = @(''GIT_AUTHOR_NAME'', ''GIT_AUTHOR_EMAIL'', ''GIT_AUTHOR_DATE'', ''GIT_COMMITTER_NAME'', ''GIT_COMMITTER_EMAIL'', ''GIT_COMMITTER_DATE'')' + ' foreach ($revLine in $revLines) {' + ' $ids = @($revLine.Trim() -split ''\s+'')' + ' $oldCommit = $ids[0]' + ' $oldParents = @($ids | Select-Object -Skip 1)' + ' $newParents = @($oldParents | ForEach-Object { if ($commitMap.Contains($_)) { $commitMap[$_] } else { $_ } })' + ' Set-GitSplitEnv ''GIT_INDEX_FILE'' $tempIndexPath' + ' try {' + ' & git -C $repoRoot read-tree $oldCommit 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git read-tree $oldCommit failed" }' + ' & git -C $repoRoot reset -q $baseCommit -- @paths 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git reset (extracted paths) for $oldCommit failed" }' + ' $tree = (& git -C $repoRoot write-tree).Trim()' + ' if ($LASTEXITCODE -ne 0 -or $tree -notmatch ''^[0-9a-f]{40}$'') { throw "git write-tree for $oldCommit failed" }' + ' }' + ' finally {' + ' Set-GitSplitEnv ''GIT_INDEX_FILE'' $savedGitEnv[''GIT_INDEX_FILE'']' + ' }' + ' if ($tree -eq (& git -C $repoRoot rev-parse "$oldCommit^{tree}").Trim() -and ($newParents -join '' '') -eq ($oldParents -join '' '')) {' + ' $commitMap[$oldCommit] = $oldCommit # untouched and not re-parented: keep it, signature and all' + ' continue' + ' }' + ' if (-not $keepEmpty -and $newParents.Count -eq 1 -and $tree -eq (& git -C $repoRoot rev-parse "$($newParents[0])^{tree}").Trim()) {' + ' $commitMap[$oldCommit] = $newParents[0] # it only changed the extracted paths: drop it' + ' $droppedCommits[$oldCommit] = $true' + ' continue' + ' }' + ' # Author/committer and message go through files, so their bytes never pass through the console encoding.' + ' & git -C $repoRoot log -1 "--pretty=format:%an%x00%ae%x00%ad%x00%cn%x00%ce%x00%cd" --date=raw "--output=$identityPath" $oldCommit' + ' if ($LASTEXITCODE -ne 0) { throw "Failed to read the author and committer of $oldCommit" }' + ' $identity = @((Get-Content -LiteralPath $identityPath -Raw -Encoding UTF8) -split "`0")' + ' if ($identity.Count -ne 6) { throw "Unexpected author/committer format for $oldCommit" }' + ' & git -C $repoRoot log -1 --pretty=format:%B "--output=$messagePath" $oldCommit' + ' if ($LASTEXITCODE -ne 0) { throw "Failed to read the message of $oldCommit" }' + ' for ($i = 0; $i -lt $identityNames.Count; $i++) { Set-GitSplitEnv $identityNames[$i] $identity[$i] }' + ' try {' + ' $parentArgs = @($newParents | ForEach-Object { ''-p''; $_ })' + ' $newCommit = (& git -C $repoRoot commit-tree $tree @parentArgs -F $messagePath).Trim()' + ' if ($LASTEXITCODE -ne 0 -or $newCommit -notmatch ''^[0-9a-f]{40}$'') { throw "git commit-tree for $oldCommit failed" }' + ' }' + ' finally {' + ' foreach ($name in $identityNames) { Set-GitSplitEnv $name $savedGitEnv[$name] }' + ' }' + ' $commitMap[$oldCommit] = $newCommit' + ' }' + ' $sourceTip = $commitMap[$expectedHead]' + ' }' + ) + } + + # --- destination commit (both modes) --- + $execLines += @( + '' + ' # --- destination: $destBase + the extracted paths'' net change across BaseRef..HEAD ---' + ' # Applied as a 3-way patch in a temporary index, never as the paths'' whole HEAD snapshot: a' + ' # snapshot would also carry any edits made to the paths between an older -DestinationBase and' + ' # BaseRef. When stacked, $destBase is the rewritten source tip, whose paths sit at BaseRef.' + ' $destBase = if ($stacked) { $sourceTip } elseif ($destinationBaseCommit) { $destinationBaseCommit } else { $baseCommit }' + ' & git -C $repoRoot -c diff.noprefix=false -c diff.mnemonicPrefix=false diff --no-ext-diff --no-color --binary --full-index --no-renames "--output=$patchPath" $baseCommit $expectedHead -- @paths 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git diff (extracted paths) failed" }' + ' Set-GitSplitEnv ''GIT_INDEX_FILE'' $tempIndexPath' + ' try {' + ' & git -C $repoRoot read-tree $destBase 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git read-tree (destination base) failed" }' + ' $applyOutput = @(& git -C $repoRoot apply --cached --3way $patchPath 2>&1 | ForEach-Object { "$_" })' + ' if ($LASTEXITCODE -ne 0) {' + ' throw ((@("The extracted paths'' changes in $baseCommit..$expectedHead do not apply cleanly onto $destBase. Choose a different -DestinationBase, or extract onto BaseRef:") + @($applyOutput | ForEach-Object { " $_" })) -join [Environment]::NewLine)' + ' }' + ' $destTree = (& git -C $repoRoot write-tree).Trim()' + ' if ($LASTEXITCODE -ne 0 -or $destTree -notmatch ''^[0-9a-f]{40}$'') { throw "git write-tree (destination) failed" }' + ' }' + ' finally {' + ' Set-GitSplitEnv ''GIT_INDEX_FILE'' $savedGitEnv[''GIT_INDEX_FILE'']' + ' }' + ' if ($destTree -eq (& git -C $repoRoot rev-parse "$destBase^{tree}").Trim()) { throw "The extracted paths'' changes are already present on $destBase; the destination branch would be empty." }' + ' $destSha = (& git -C $repoRoot commit-tree $destTree -p $destBase -m $destinationMessage).Trim()' + ' if ($LASTEXITCODE -ne 0 -or $destSha -notmatch ''^[0-9a-f]{40}$'') { throw "Failed to create the destination commit." }' + ) + + # --- ref updates (back in the main repo) --- + $execLines += @( + '' + ' # --- create the destination branch ---' + ' & git update-ref "refs/heads/$destinationBranch" $destSha 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git update-ref (destination) failed" }' + ) + if ($removeFromSource) { + $execLines += @( + '' + ' # --- rewrite the source branch to drop the extracted paths (destructive by default) ---' + ' & git update-ref "refs/heads/$expectedBranch" $sourceTip $expectedHead 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git update-ref (source) failed" }' + ' & git @longPathGitArgs reset --hard "refs/heads/$expectedBranch" 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git reset --hard (source sync) failed" }' + ) + } + else { + $execLines += @( + '' + ' # --- copy mode: source branch left untouched at $expectedHead ---' + ) + } + + # --- push (opt-in) --- + $execLines += @( + '' + ' if ($push) {' + ' & git push -u origin $destinationBranch 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git push (destination) failed" }' + ) + if ($removeFromSource) { + $execLines += @( + ' if ($forcePushSource) {' + ' & git push --force-with-lease origin $expectedBranch 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git push --force-with-lease (source) failed" }' + ' }' + ' else {' + ' Write-Warning "Source branch ''$expectedBranch'' was rewritten but -ForcePushSource was not set; source not pushed. Push manually: git push --force-with-lease origin $expectedBranch"' + ' }' + ) + } + $execLines += @( + ' }' + '' + ' $succeeded = $true' + ' Write-Host "Split-ByPath complete."' + ' if ($removeFromSource) {' + ' Write-Host " source: $expectedBranch $expectedHead -> $sourceTip"' + ' }' + ' else {' + ' Write-Host " source: $expectedBranch $expectedHead (unchanged)"' + ' }' + ' Write-Host " destination: $destinationBranch -> $destSha"' + ' if ($commitMap.Count -gt 0) {' + ' Write-Host " rewritten commits (old -> new):"' + ' foreach ($entry in $commitMap.GetEnumerator()) {' + ' $newLabel = if ($droppedCommits.ContainsKey($entry.Key)) { "dropped (it only changed the extracted paths)" } else { $entry.Value }' + ' Write-Host " $($entry.Key) -> $newLabel"' + ' }' + ' }' + ' elseif ($removeFromSource) {' + ' Write-Host " every commit in $baseCommit..$expectedHead was squashed into $sourceTip"' + ' }' + ' Write-Host " (History rewrite changes commit SHAs; old SHAs cited in review threads may become dangling on GitHub after force-push.)"' + '}' + 'finally {' + ) + + $execLines += @( + ' foreach ($name in $gitEnvNames) { Set-GitSplitEnv $name $savedGitEnv[$name] }' + ' if ($scratchCreated -and (Test-Path -LiteralPath $scratchPath)) {' + ' Remove-Item -LiteralPath $scratchPath -Recurse -Force -ErrorAction SilentlyContinue' + ' }' + ' if ($worktreeCreated -and $worktreePath -and (Test-Path -LiteralPath $worktreePath)) {' + ' & git @longPathGitArgs worktree remove --force $worktreePath 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { Write-Warning "Failed to remove worktree at ''$worktreePath''. Run: git worktree remove --force ''$worktreePath''" }' + ' }' + ' if (Test-Path -LiteralPath $disabledHooksPath) { Remove-Item -LiteralPath $disabledHooksPath -Recurse -Force -ErrorAction SilentlyContinue }' + ' if ($stashed) {' + ' $gitDir = (& git rev-parse --git-dir).Trim()' + ' if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($gitDir)) { throw "Split-ByPath created a stash ''$stashName'' but failed to resolve the git directory for restoration." }' + ' if (-not [System.IO.Path]::IsPathRooted($gitDir)) { $gitDir = Join-Path $repoRoot $gitDir }' + ' $stashLines = @(& git stash list --format="%gd %s")' + ' if ($LASTEXITCODE -ne 0) { throw "Split-ByPath created a stash ''$stashName'' but failed to inspect the stash list for restoration." }' + ' $stashLine = $stashLines | Where-Object { $_ -like "*$stashName*" } | Select-Object -First 1' + ' if ([string]::IsNullOrWhiteSpace($stashLine)) { throw "Split-ByPath created a stash ''$stashName'' but could not find it for restoration." }' + ' $stashRef = ($stashLine -split ''\s+'', 2)[0]' + ' $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 @(' + ' "Split-ByPath 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 "' + ' ) -join [Environment]::NewLine' + ' }' + ' else {' + ' & git stash pop $stashRef 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "Failed to restore stash $stashRef created by Split-ByPath." }' + ' }' + ' }' + '}' + '$destinationBranch' + ) + + $steps += New-GitStep -Kind Comment -Lines @( + 'Build the source (squash: in an isolated worktree; preserve: replayed in a temporary index) and the', + 'destination (a patch applied in a temporary index), then update refs in the main repo.', + 'Cleanup removes the temporary worktree and scratch files and restores any stash on all exit paths.' + ) + $steps += New-GitStep -Kind Literal -Lines $execLines + + return New-GitPlan -Name 'Split-ByPath' -Metadata @{ + SourceBranch = $currentBranch + SourceHead = $currentHead + BaseCommit = $baseCommit + DestinationBranch = $DestinationBranch + Paths = $normalizedPaths + Squash = [bool]$squash + RemoveFromSource = [bool]$removeFromSource + Stacked = [bool]$stacked + DestinationBaseCommit = $destinationBaseCommit + Push = [bool]$Push + AutoStash = [bool]$AutoStash + OutputScriptCapable = $true + } -Steps $steps +} + +function Split-ByPath { + <# + .SYNOPSIS + Extracts a set of paths' net change across BaseRef..HEAD into a separate branch. + + .DESCRIPTION + Range-aware counterpart to Split-Commit / Move-Commit. Extracts the net change to -Path across the + BaseRef..HEAD range into -DestinationBranch, and (destructive by default) rewrites the current + branch to drop those paths' changes. See New-SplitByPathPlan for full semantics. + + .PARAMETER Path + One or more repo-relative paths to extract (matched at current HEAD names). + + .PARAMETER DestinationBranch + The new branch to receive the extracted paths' net change. Must not already exist (locally or on + origin); a split creates a new PR branch. + + .PARAMETER BaseRef + Range base. Defaults to merge-base(HEAD, origin/HEAD). Must be an ancestor of HEAD. + + .PARAMETER DestinationBase + Where to root the destination. Default: stacked on the rewritten source tip (when -RemoveFromSource) + or flat on BaseRef (copy mode). Pass an explicit ref for a flat dest rooted elsewhere. + + .PARAMETER Squash + Default $true: collapse the source to one squashed commit (pure git). -Squash:$false preserves the + source commit structure, replaying each commit with the paths held at their BaseRef state. + + .PARAMETER RemoveFromSource + Default $true (DESTRUCTIVE): rewrite the source branch to drop the extracted paths, like `mv`. Pass + -RemoveFromSource:$false for a non-destructive copy (source untouched). + + .PARAMETER Push + Push the destination branch (and source, with -ForcePushSource, if rewritten). + + .PARAMETER ForcePushSource + Force-push the rewritten source branch with --force-with-lease. + + .PARAMETER AutoStash + Stash uncommitted changes before the split and restore them after. + + .PARAMETER KeepEmpty + Preserve mode only: keep commits that become empty once the paths' changes are removed. + + .PARAMETER OutputScriptPath + Write a reviewable script instead of executing immediately. + + .NOTES + Destructive by default (-RemoveFromSource). This is intentional and mirrors `mv`; the opt-out is + -RemoveFromSource:$false. The emitted scripts are the review surface. See New-SplitByPathPlan. + .EXAMPLE + Split-ByPath -Path '.github/workflows/ci.yml' -DestinationBranch 'ci-split' + # Extracts ci.yml's net change into ci-split (stacked on the rewritten source), removes it from the current branch. + + .EXAMPLE + Split-ByPath -Path 'src/a.ts','src/b.ts' -DestinationBranch 'feat-ts' -DestinationBase 'main' -RemoveFromSource:$false + # Copy mode: source untouched, flat dest on main containing the two files' net change. + #> + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([string])] + param( + [Parameter(Mandatory = $true, Position = 0)] + [ValidateNotNullOrEmpty()] + [string[]]$Path, + + [Parameter(Mandatory = $true, Position = 1)] + [ValidateNotNullOrEmpty()] + [string]$DestinationBranch, + + [Parameter()] + [string]$BaseRef, + + [Parameter()] + [string]$DestinationBase, + + [Parameter()] + [switch]$Squash, + + [Parameter()] + [switch]$RemoveFromSource, + + [Parameter()] + [string]$SourceMessage, + + [Parameter()] + [string]$DestinationMessage, + + [Parameter()] + [switch]$Push, + + [Parameter()] + [switch]$ForcePushSource, + + [Parameter()] + [switch]$AutoStash, + + [Parameter()] + [switch]$KeepEmpty, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$OutputScriptPath + ) + + # -Squash and -RemoveFromSource default to $true (destructive squash is the default). A PowerShell + # [switch] defaults to $false and is only "present" in $PSBoundParameters when explicitly passed, so + # the plan builder keys its $true default off $PSBoundParameters.ContainsKey. Passing these switches + # unconditionally here (as -Squash:$Squash) would always make them "present" and invert the default. + # Only forward them when the caller actually set them. + $planParams = @{ + Path = $Path + DestinationBranch = $DestinationBranch + BaseRef = $BaseRef + DestinationBase = $DestinationBase + SourceMessage = $SourceMessage + DestinationMessage = $DestinationMessage + Push = $Push + ForcePushSource = $ForcePushSource + AutoStash = $AutoStash + KeepEmpty = $KeepEmpty + } + if ($PSBoundParameters.ContainsKey('Squash')) { $planParams['Squash'] = [bool]$Squash } + if ($PSBoundParameters.ContainsKey('RemoveFromSource')) { $planParams['RemoveFromSource'] = [bool]$RemoveFromSource } + + $plan = New-SplitByPathPlan @planParams + + if ($OutputScriptPath) { + if ($PSCmdlet.ShouldProcess($OutputScriptPath, 'Write Split-ByPath execution script')) { + return Write-GitScript -Plan $plan -Path $OutputScriptPath + } + return + } + + $action = if ($plan.Metadata.RemoveFromSource) { + "Split paths into $DestinationBranch and remove them from $($plan.Metadata.SourceBranch)" + } + else { + "Copy paths into $DestinationBranch (source unchanged)" + } + + if ($PSCmdlet.ShouldProcess($DestinationBranch, $action)) { + return Invoke-GitPlan -Plan $plan + } +} + if ($env:CI) { Write-Host "Exporting all module members for CI environment." Export-ModuleMember * @@ -4620,6 +5354,7 @@ else { 'Add-Commit' 'Remove-Commit' 'Move-Commit' + 'Split-ByPath' 'Set-CommitOrder' 'Invoke-GitSplitAbsorb' 'Get-CommitMessageFromChanges' diff --git a/docs/design-split-bypath.md b/docs/design-split-bypath.md new file mode 100644 index 0000000..c1f4e4a --- /dev/null +++ b/docs/design-split-bypath.md @@ -0,0 +1,479 @@ +# Design: `Split-ByPath` — extract a set of paths across a branch range into a separate PR + +Status: **Implemented** in this PR (`feature/split-bypath`). Target module: `GitSplit.psm1`. See `Split-ByPath` / `New-SplitByPathPlan`. + +## 1. Problem + +Every existing GitSplit verb takes a **single commit** (`-Ref `): + +| Verb | Operates on | What it does | +|---|---|---| +| `Split-Commit` | one commit | carves it by hunk / line / whole-file | +| `Select-GitSplitPaths` | one commit | selects changed paths by regex | +| `Move-Commit` | one commit | cherry-picks it to another branch | +| `Remove-Commit` | one commit | drops it from a branch (rebase) | + +There is **no range awareness** — no `BaseRef..HEAD`. The common real-world +scenario these verbs cannot express: *one file's changes are interleaved across +many commits in a branch, and you want them as a single separate PR.* This came +up concretely when splitting `.github/workflows/pr-e2e-playwright.yml` out of a +21-commit driver branch (immybot PR #9431 → #9441). The resolution was done by +hand with `git reset --soft ` + `git restore --staged `; this design +captures that as a first-class verb. + +**Scope (per maintainer):** GitSplit handles *tedious mechanical git operations*. +**Out of scope:** build/test guards. `Split-ByPath` does not compile or test the +result; it performs git operations and leaves buildability to the caller. This is +deliberate — GitSplit is a git tool, not a CI gate. + +## 2. Synopsis + +```powershell +Split-ByPath + -Path # paths to extract (repo-relative; matched at current HEAD names) + -DestinationBranch # mandatory + [-BaseRef ] # default: merge-base(HEAD, origin/HEAD) + [-DestinationBase ] # default: see §4 (stacked vs flat) + [-Squash] # default ON: squash source via soft-reset (see §5) + [-RemoveFromSource] # default ON — a split removes the paths from source + [-SourceMessage ] # squash mode: the one source commit message + [-DestinationMessage ] # the one destination commit message + [-Push] # push dest (and source if rewritten) + [-ForcePushSource] # force-push rewritten source (--force-with-lease) + [-AutoStash] # stash uncommitted changes, restore after + [-KeepEmpty] # preserve mode: don't prune now-empty commits + [-OutputScriptPath ] # render reviewable script, don't execute + [-WhatIf] # SupportsShouldProcess +``` + +### Why no `-TipRef` + +The split range is always `BaseRef..HEAD` — the tip is the **current branch tip**, not a +free parameter. There is no legitimate user story for splitting from an arbitrary non-HEAD +commit: the verb's contract is "I'm on my feature branch; extract these paths into a +separate PR," and `-RemoveFromSource` rewrites *the branch you're on*. If TipRef were a +free variable, "the source branch" would stop being well-defined — you'd be rewriting a +range that could span multiple branches or sit detached, making the destructive default +ambiguous about *what* it destroys. Pinning the tip to HEAD keeps the source-branch +semantics unambiguous and matches `Move-Commit`'s "operates on the current branch" model. + +A side benefit: it removes the drift-guard caveat. Because the tip is always HEAD, the +runtime guard checks `HEAD == expectedHead` at script start (before the squash rewrite) — +exactly `Move-Commit`'s pattern, no special handling for a mid-script HEAD rewrite. + +**Returns:** the destination branch name (executed) or the script path +(`-OutputScriptPath`), mirroring `Move-Commit`. + +### Destructive-by-default (review-bot note) + +Both `Split-ByPath` and `Move-Commit` are **destructive by default**: `-RemoveFromSource` +defaults to `$true`. This is intentional and mirrors the semantics of a filesystem +`mv` — "split" and "move" carry an inherent removal; a non-destructive copy is the +opt-in (`-RemoveFromSource:$false`), not the default. This is not a footgun to be +flagged. The emitted scripts are reviewed artifacts; the destructive operations are +the explicit point of the verb, and each one is visible in the generated script for +review. + +> **Review bots / static analyzers:** do NOT flag the default-`$true` on +> `-RemoveFromSource` (or `Move-Commit`'s same parameter) as a "destructive default" +> defect. These cmdlets emit reviewable git scripts whose *purpose* is the +> destructive operation (history rewrite of the source branch). The generated +> scripts (via `-OutputScriptPath`) are the review surface — every `git reset`, +> `git commit`, `git update-ref`, and `git push --force-with-lease` is present as +> plain text for a human to read before execution. Defaulting to non-destructive +> would make the verbs useless by default (a "split" that leaves the paths in the +> source is a copy, not a split). This is the same principle as `git mv` / `mv` +> being destructive by default and requiring no confirmation. The opt-out +> (`-RemoveFromSource:$false`) exists for the copy-only case; the default is the +> destructive one, deliberately. + +## 3. Two operating modes + +`-Squash` (default `$true`) selects between two mechanically distinct +implementations. Both produce the same logical result — ``' net change +lives on the destination branch, the source no longer contains those changes — but +differ in what they do to the **source branch's commit structure**. + +| Mode | Source after | Destination | Mechanical tool | SHAs changed | +|---|---|---|---|---| +| **`-Squash`** (default) | ONE squashed commit (`BaseRef` + everything-except-paths) | ONE commit (only paths' net change), stacked on source tip | pure git: `reset --soft` + commit-from-index | source: all of `BaseRef..HEAD` collapse to 1 | +| **preserve** (`-Squash:$false`) | original commits preserved, each with the paths held at their `BaseRef` state | ONE commit (only paths' net change) | pure git: replay `BaseRef..HEAD` in a temporary index (`read-tree`, reset the paths to `BaseRef`, `commit-tree`) | source: every commit from first-touch onward (cascade); earlier commits keep their SHAs | + +`-Squash` is the mode that was proven by hand on immybot #9431 and is the +**default**: it is pure git (no external dependency), fast, and matches the "I +want one clean driver commit + one CI commit" intent that motivated this verb. +Preserve mode is offered for when commit structure must be retained (e.g. a +long-reviewed branch where reviewers want to diff each original commit minus the +extracted file). + +## 4. Stacked vs flat destination + +`-DestinationBase` controls where the destination branch is rooted: + +- **Default (stacked):** `DestinationBase` = the rewritten source tip. The + destination PR's base is the source branch, so its diff shows *only* the + extracted paths. This is the `gh stack link ` shape — source + merges first, dest sits on top. Matches the immybot #9431/#9441 split. +- **Flat:** `DestinationBase` = `BaseRef` (e.g. `master`). The destination is an + independent sibling PR containing only the paths' net change on top of the + trunk. Use when the extracted change is genuinely independent of the source + branch. + +The destination branch must not exist yet, locally or on `origin`: a split always +creates a new branch, so there is no `-CreateDestinationBranch` switch. An +existing ref throws with delete-hints. + +**How the destination commit is built (both modes).** The paths' net change, +`git diff BaseRef HEAD -- `, is applied as a 3-way patch onto the +destination base in a temporary index (`read-tree `, `apply --cached +--3way`, `write-tree`, `commit-tree -p `). It is never the paths' whole +`HEAD` snapshot: with an explicit `-DestinationBase` older than `BaseRef`, a +snapshot would also carry every edit made to the paths between the two. If the +change does not apply cleanly (for example, the destination base lacks a file the +range modifies), the plan throws before any ref moves and shows git's conflict +output. + +## 5. `-Squash` implementation — commit-from-index (pure git, no deps) + +### 5.1 The technique (why commit-from-index, not reconstruct-via-checkout) + +The implementation is built from `New-GitStep -Kind Literal` (so it renders via +`Write-GitScript` AND executes via `Invoke-GitPlan`, exactly like every other plan +in the module). All work happens in a temp worktree (`New-GitSplitWorktreePath`) +so the caller's working tree is never disturbed — same isolation pattern as +`Move-Commit`. + +The core insight: after `git reset --soft `, the **index already equals +the HEAD tree** — it encodes every path's correct lifecycle (adds, modifies, +*and deletions*) as the correct staged state. Committing paths *from the index* +records the right thing with zero reconstruction logic. The earlier draft +reconstructed the destination tree with `git checkout HEAD -- `, which +**cannot express a deletion** (the path doesn't exist at HEAD to check out) and +needed a `git cat-file -e` / `git rm` special-case (former §7.2). Committing from +the index eliminates that entire edge case — a deleted path is staged as a +deletion, and `git commit -- ` records it correctly. **This is the single +biggest correctness improvement over the reconstruct technique and the reason the +soft-reset approach is the right one.** + +### 5.2 The plan (stacked destination — `DestinationBase` = rewritten source tip) + +```bash +# temp worktree checked out at current branch HEAD (no branch switch in caller's tree) +git reset --soft "$BaseRef" # index = HEAD tree; HEAD at BaseRef +# commit the extract paths FIRST (from the index — correct for add/modify/delete): +git commit -m "$DestinationMessage" -- "${Paths[@]}" +extractSha=$(git rev-parse HEAD) # BaseRef + only the paths' net change +# commit everything else (the squashed source = HEAD minus the paths): +git commit -m "$SourceMessage" -- . # remaining staged change set +sourceTip=$(git rev-parse HEAD) # BaseRef + everything EXCEPT paths +# destination = the extract commit, re-pointed as its own branch: +git branch -f "$DestinationBranch" "$extractSha" +# (branch -f on a fresh name == create; equivalent to checkout -B then reset) +``` + +The destination branch points at `extractSha`, whose parent is `BaseRef` — i.e. a +**flat** dest rooted at `BaseRef`, not stacked on `sourceTip`. For a genuinely +**stacked** dest (dest base = source tip, so dest diff shows only the paths on +top of the full source), reorder: commit the rest first, then commit the paths on +top, and reparent: + +```bash +git reset --soft "$BaseRef" +git commit -m "$SourceMessage" -- "${PathsToKeep[@]}" # everything except extract paths +sourceTip=$(git rev-parse HEAD) +git commit -m "$DestinationMessage" -- "${Paths[@]}" # extract paths on top of source +git branch -f "$DestinationBranch" HEAD # dest parented on sourceTip -> stacked +``` + +The plan builder chooses the order from `DestinationBase`: if it resolves to +`BaseRef` → flat order (paths first); if it resolves to the rewritten source tip +→ stacked order (paths last). Both are commit-from-index; the difference is purely +commit order and which commit the dest branch points at. + +### 5.3 Flat destination (`DestinationBase` = `BaseRef`) + +Same as the flat-order variant above — the dest branch points at the +paths-first commit, parented on `BaseRef`. No `git checkout BaseRef` round-trip is +needed because `reset --soft` already left HEAD at `BaseRef`. + +### 5.4 After the worktree succeeds + +The real source branch ref is updated to `sourceTip` (destructive-by-default: +`-RemoveFromSource` is `$true`, so the source is rewritten — only with +`-RemoveFromSource:$false` is the source left untouched and the operation becomes +a copy). The destination branch ref is created at its commit. Push is opt-in +(`-Push`, `-ForcePushSource`), emitting `git push --force-with-lease` for the +rewritten source — matching `Move-Commit`'s push discipline. + +## 6. Preserve implementation (pure-git replay) + +Preserve mode replays `BaseRef..HEAD` (`git rev-list --reverse --topo-order +--parents`) and rebuilds each commit's tree in a temporary index: + +1. `read-tree ` loads the original tree. +2. `reset -q -- ` puts each extracted path back to its `BaseRef` + state: restored when it existed there, removed when it did not. +3. `write-tree` produces the new tree, and `commit-tree` re-commits it on the + rewritten parents with the original author, committer, and message. Those are + read through files (`git log --output`), so their bytes never pass through the + console encoding. + +Commits whose tree and parents come out unchanged keep their SHA (and any +signature). Commits that only changed the extracted paths come out identical to +their parent and are dropped unless `-KeepEmpty` is set. The destination commit +is then built exactly as in §4. + +**Why not `git filter-repo`.** The first cut ran `filter-repo --invert-paths` in a +fresh `--no-hardlinks` clone and fetched the result back. That had two +correctness problems. `--invert-paths` deletes the paths from *every* commit, so a +file that already existed at `BaseRef` vanished from the source instead of going +back to its `BaseRef` content. And filter-repo rewrites all history that touched +the paths, including commits before `BaseRef` (stripping their signatures), so the +source stopped sharing history with its trunk. The replay touches only +`BaseRef..HEAD`, needs no clone, and drops the external dependency, so preserve +mode also runs in CI. + +## 7. Edge cases + +These are the cases that make a naïve `git diff | apply` wrong. Each must be +detected at plan-build time (the discovery phase that freezes `$expected*` +values) and either handled or thrown on — never silently mishandled. + +### 7.1 Range / selection guards +- **`BaseRef == HEAD`** (no commits in range): throw `"BaseRef and HEAD resolve to the same commit; nothing to split."` +- **Paths match no changes in `BaseRef..HEAD`**: compute `git diff --name-only BaseRef..HEAD -- `; if empty, throw `"No changes to in $BaseRef..HEAD; nothing to extract."` (fail loud rather than creating an empty dest commit). +- **`BaseRef` is not an ancestor of `HEAD`**: throw (use `Test-GitCommitIsAncestor`, already in the module). This is the same guard `New-CommitRemovalRewritePlan` uses. +- **Detached HEAD / not on a branch**: throw, mirroring `Move-Commit` (the tip is always the current branch HEAD). + +### 7.2 Path lifecycle within the range — handled by commit-from-index +- **Path added in the range (absent at `BaseRef`):** after `reset --soft`, the add + is staged in the index; `git commit -- ` records it. No special handling. +- **Path deleted in the range (absent at `HEAD`):** the deletion is staged in + the index (the path is absent from the HEAD-equivalent index); `git commit -- + ` records the deletion. **No `git cat-file -e` / `git rm` special-case is + needed** — this is the whole point of commit-from-index over the former + reconstruct-via-checkout technique, which could not express a deletion. +- **Path unchanged in the range but matched by filter:** contributes nothing to + the net diff; harmless (collapses to a no-op for that path in the index commit; + preserve mode leaves those commits as they were). + The §7.1 "no changes at all" guard still fires if *every* matched path is unchanged. +- **Path renamed within the range:** paths are matched at **HEAD names**. A path + matched by its old name won't follow the rename. Document this limitation; + recommend selecting the HEAD name. Both modes share this limitation. + +### 7.3 Empty-commit pruning (preserve only) +- The replay drops a single-parent commit whose rebuilt tree equals its new + parent's, meaning it only changed the extracted paths. This **changes the + commit count**, which surprises anyone diffing before/after, so the completion + output lists each dropped commit. `-KeepEmpty` keeps such commits as empty + markers. (Squash mode is unaffected — it collapses to one commit by design.) + +### 7.4 History-rewrite consequences (both modes) +- **SHA cascade:** any mode that rewrites the source changes SHAs from the first + affected commit onward (squash: the whole range collapses to one new SHA; + preserve: every commit from first-path-touch onward is re-hashed, because each + embeds its parent's SHA). This is mathematically unavoidable — a commit SHA is a + hash of its tree + parent + metadata. Document prominently. +- **Orphaned review-thread citations:** if the source branch has an open PR, SHAs + cited in resolved review threads become dangling on GitHub after force-push + (the old commits are unreachable and eventually GC'd remotely). The plan prints + the old→new map on completion, but GitHub cannot follow it. + This happened on immybot #9431 (c6a15aa8be, 44e6f07a6c, etc. went dark after the + squash). Mitigation: `-OutputScriptPath` lets the user review the exact rewrite + before running it; the plan emits the old→new SHA map to the console on + completion (preserve: one line per rewritten or dropped commit; squash: the old + and new tip) so the user can update citations. **No automatic PR-comment rewriting** + — out of scope (mechanical git only). +- **Force-push required:** rewritten source is non-fast-forward; emit + `git push --force-with-lease` only when `-Push` + `-ForcePushSource` are set + (opt-in, matching `Move-Commit`). Never push by default. + +### 7.5 Working-tree & drift guards (reuse existing patterns) +- **Unclean working tree:** mirror `Move-Commit`'s `AutoStash` guard — throw + listing the dirty files, or stash/restore with `-AutoStash`. A history rewrite + on a dirty tree is undefined. +- **Drift between plan-build and execute:** the plan freezes `$expectedRepoRoot`, + `$expectedBranch`, `$expectedHead` and re-checks at runtime, throwing on + mismatch — copy `New-MoveCommitPlan`'s guard block verbatim. Because the tip is + always the current branch HEAD (no `-TipRef`), the guard checks `HEAD == + $expectedHead` at script start, *before* the squash rewrite — exactly + `Move-Commit`'s pattern. No special handling for the mid-script HEAD rewrite is + needed; the guard has already passed by the time `git reset --soft` runs. +- **Destination branch already exists:** throw with delete-hints, whether the + ref exists locally or on `origin` (a split always creates the destination). + +### 7.6 Trunk default +- **Trunk default:** `BaseRef` defaults to `merge-base(HEAD, origin/HEAD)` + (resolve `origin/HEAD` via `git symbolic-ref refs/remotes/origin/HEAD`), + matching the "split a branch off the default branch" intent. Overridable. If + `origin/HEAD` is unset, require explicit `-BaseRef` (throw with a hint to set + it or run `git remote set-head`). + +## 8. Plan shape & the reuse question (considered, deferred) + +### 8.1 Internal API + +Follows the module's existing plan/execute split exactly. A new +`New-SplitByPathPlan` builds a `New-GitPlan` whose `Steps` are +`New-GitStep -Kind Comment|Literal`; the public `Split-ByPath` cmdlet either +`Write-GitScript` (with `-OutputScriptPath`) or `Invoke-GitPlan`. The same plan +object is both the reviewable artifact and the executable — no second code path. + +```powershell +function New-SplitByPathPlan { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string[]] $Path, + [Parameter(Mandatory)] [string] $DestinationBranch, + [Parameter()] [string] $BaseRef, # default: merge-base(HEAD, origin/HEAD) + [Parameter()] [string] $DestinationBase, # default: rewritten source tip (stacked) + [Parameter()] [switch] $Squash, # default $true + [Parameter()] [switch] $RemoveFromSource, # default $true (destructive by default) + [Parameter()] [string] $SourceMessage, + [Parameter()] [string] $DestinationMessage, + [Parameter()] [switch] $Push, + [Parameter()] [switch] $ForcePushSource, + [Parameter()] [switch] $AutoStash, + [Parameter()] [switch] $KeepEmpty + ) + # 1. discovery: resolve HEAD/BaseRef, run §7 guards, choose commit order + # from DestinationBase (flat: paths-first; stacked: paths-last). + # 2. choose impl: $Squash -> §5 commit-from-index literal steps; + # else -> §6 pure-git replay in a temporary index. + # 3. emit $expected* frozen values + runtime drift guards (copied from + # New-MoveCommitPlan; check HEAD == expectedHead at script start, before + # the squash rewrite). + # 4. temp worktree (squash) + scratch files (both) setup + finally-cleanup. + # 5. ref updates: update source (force, if RemoveFromSource) + create dest. + # (destructive-by-default — see §2 review-bot note.) + # 6. push (opt-in): git push --force-with-lease for source; git push for dest. + # 7. on completion: Write-Host the old->new SHA map (preserve) / collapsed SHA (squash). +} + +function Split-ByPath { + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([string])] + param( /* same params + [string]$OutputScriptPath */ ) + $plan = New-SplitByPathPlan @PSBoundParameters + if ($OutputScriptPath) { + if ($PSCmdlet.ShouldProcess($OutputScriptPath, 'Write Split-ByPath script')) { + return Write-GitScript -Plan $plan -Path $OutputScriptPath + } + return + } + if ($PSCmdlet.ShouldProcess($DestinationBranch, "Split paths into $DestinationBranch")) { + return Invoke-GitPlan -Plan $plan + } +} +``` + +Export in both the `Export-ModuleMember` list (end of `GitSplit.psm1`) **and** +`GitSplit.psd1` `FunctionsToExport` — the module filters through *both* lists, so +keep them aligned (the existing comment in the psm1 warns about this). + +### 8.2 "Reuse Move-Commit's logic" — considered, deferred + +A natural framing: soft-reset → commit the paths (one extract commit) → commit +the rest (one source commit) → call `Move-Commit -CommitRef -RemoveFromSource` +to move the extract commit to the dest branch and excise it from source. This would +inherit Move-Commit's destination-branch creation, existing-branch throw, +`AutoStash`, push/`--force-with-lease`, and `OutputScriptPath` machinery for free, +shrinking Split-ByPath to a soft-reset prelude. + +**This is deferred for a concrete reason, not rejected.** GitSplit plans render to +self-contained, **pure-git PowerShell scripts** (no module import — +`ConvertTo-GitScript` emits bare `git` commands), and `New-MoveCommitPlan` freezes +`$expectedHead` at discovery time and re-checks it at runtime as a drift guard. +Split-ByPath's soft-reset **rewrites HEAD in the middle of the script**, so: + +- **Splicing Move-Commit's plan steps in** → the frozen `$expectedHead` is the + *original* head (discovery precedes execution), but by the time those steps run + HEAD has moved → the drift guard throws. The guards are incompatible with a + mid-script rewrite. +- **Calling `Move-Commit` as a cmdlet from the generated script** → the script + must `Import-Module GitSplit`, breaking the pure-git standalone invariant every + other plan relies on. + +**Clean reuse requires a refactor first:** split `New-MoveCommitPlan` into a +guard-free **core** (the cherry-pick / create / remove steps) and the drift-guard +wrapper, so Split-ByPath can splice the core and attach guards appropriate to its +own rewrite-aware flow. That refactor also benefits `Remove-Commit` and +`Set-CommitOrder` (likely the same guard/core tension). It is a contained, +worthwhile follow-up — but it is **not a prerequisite for shipping Split-ByPath**, +because the commit-from-index squash plan is short and standalone (§5). + +**Phasing:** +1. **First cut (this PR):** emit inline commit-from-index git for the dest + + excise. Clean standalone scripts, no Move-Commit call, no refactor. The + deletion-correctness win (§5.1) does not depend on reuse. +2. **Follow-up PR:** refactor `New-MoveCommitPlan` into core + guard wrapper, then + have Split-ByPath splice the core. Real reuse; pays off across the other + rewrite verbs. Not gated on the first cut. + +## 9. Tests (`GitSplit.Tests.ps1`) + +Mirror the existing `Move-Commit` / `Split-Commit` Pester cases. Minimum matrix: + +| Case | Mode | Asserts | +|---|---|---| +| basic add, stacked dest | squash | source = 1 commit w/o path; dest = 1 commit w/ path, parent = source tip | +| basic add, flat dest | squash | dest parent = BaseRef; dest contains only path; source has path reverted | +| path added in range | squash | dest records the add; source no longer has the file | +| path deleted in range | squash | dest records a deletion (commit-from-index, no git rm special-case); source has the file restored | +| modify across >1 commit | squash | source squash = net non-path change; dest = net path change (interleaved changes collapse correctly) | +| RemoveFromSource:$false (copy) | squash | source untouched (still at HEAD); dest = paths' net change | +| no changes to paths | squash | throws (§7.1) | +| BaseRef==HEAD | squash | throws | +| dest branch exists | squash | throws with delete-hints | +| older DestinationBase | squash | dest = DestinationBase + only the `BaseRef..HEAD` path change (no edits from before `BaseRef`) | +| change doesn't apply onto DestinationBase | squash | throws "do not apply cleanly"; no ref moves | +| unclean tree, no AutoStash | squash | throws listing files | +| unclean tree, AutoStash | squash | stashes, splits, restores | +| OutputScriptPath | squash | script renders, is re-runnable, drift guards fire when HEAD changes between generate and execute | +| destructive-by-default | squash | without `-RemoveFromSource:$false`, source ref moves (rewritten); documented as intended | +| preserve: structure retained | preserve | only `BaseRef..HEAD` rewritten; commit count preserved (minus dropped commits); paths at their `BaseRef` state in each commit | +| preserve: KeepEmpty | preserve | commits that only changed the paths are kept as empty markers | +| preserve: untouched / path-only commits | preserve | untouched commits keep their SHA; path-only commits are dropped and listed | + +Preserve mode needs nothing beyond git, so its cases run everywhere, CI included. + +## 10. Future / out of scope + +- **Reuse Move-Commit core:** refactor `New-MoveCommitPlan` into core + guard + wrapper (§8.2), so Split-ByPath (and `Remove-Commit`/`Set-CommitOrder`) splice + the shared core. Follow-up PR. +- **PR/stack awareness:** after a `-RemoveFromSource` split, auto-run + `gh stack link ` and update the source PR's head. Out of scope for + the git-only verb; belongs in a higher-level `pr-auto`-style caller. The verb + should print the exact `gh stack link` command to run, nothing more. +- **Build/test guards:** explicitly out of scope per maintainer. The verb does not + compile or test; if the caller wants a buildability check they wrap the call. +- **Review-thread SHA migration:** rewriting cited SHAs in GitHub threads is out of + scope; the verb only prints the local old→new map. + +## 11. Resolved decisions + +1. **Default mode:** `-Squash` is the default (no external dep, matches the proven + immybot flow). Preserve is opt-in via `-Squash:$false`. +2. **Destructive by default:** `-RemoveFromSource` defaults `$true` — a "split" + removes the paths from the source, consistent with `Move-Commit`'s intended + destructive-by-default semantics. See §2 review-bot note. The opt-out + (`-RemoveFromSource:$false`) covers the copy-only case. +3. **Commit-from-index, not reconstruct-via-checkout:** the squash mode commits + paths from the index after `reset --soft`, eliminating the deletion edge case + (former §7.2). This supersedes the earlier reconstruct technique. +4. **No filter-repo dependency:** preserve mode replays `BaseRef..HEAD` in pure + git (§6). The first cut's `filter-repo --invert-paths` engine deleted files + that existed at `BaseRef` and rewrote history before it. +5. **Trunk default:** `merge-base(HEAD, origin/HEAD)`, overridable via `-BaseRef`. + If `origin/HEAD` is unset, require explicit `-BaseRef`. +6. **No `-TipRef`:** the tip is always the current branch HEAD. Dropped from the + earlier draft — no legitimate user story for splitting from an arbitrary + non-HEAD commit, and a free TipRef makes the destructive `-RemoveFromSource` + ambiguous about *which* branch it rewrites. Pinning to HEAD keeps source-branch + semantics unambiguous (matches `Move-Commit`) and removes the drift-guard + caveat (the guard checks `HEAD == expectedHead` at script start, before the + squash rewrite — no special mid-script handling needed). +6. **Move-Commit reuse:** deferred to a follow-up PR (§8.2) — the first cut emits + standalone commit-from-index git and does not depend on the + `New-MoveCommitPlan` core/guard refactor.