From 4829ebb29501afca7590f714a6f7241f56075b9e Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 4 Aug 2026 08:57:38 -0500 Subject: [PATCH] =?UTF-8?q?feature:=20add=20Split-ByPath=20=E2=80=94=20ran?= =?UTF-8?q?ge-aware=20path=20extraction=20into=20a=20separate=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split-ByPath extracts the net change to a set of paths across a BaseRef..HEAD range into a new destination branch, and (destructive by default, mirroring `mv`) rewrites the current branch to drop those paths' changes. It is the range-aware counterpart to Split-Commit / Move-Commit, which both operate on a single commit. Two modes: - -Squash (default): pure git commit-from-index. `git reset --soft BaseRef` stages the whole range; commit the remainder, then stage + commit the extract paths. Handles adds, modifies, and deletions uniformly (no `git rm` special case). Source collapses to one squashed commit. - -Squash:$false (preserve): git-filter-repo --invert-paths in a temp clone, fetched back. Keeps the source's commit structure with the paths excised. Destination defaults to stacked on the rewritten source tip (PR diff shows only the extracted paths); -DestinationBase or copy mode gives a flat sibling. The builder (New-SplitByPathPlan) emits a reviewable pure-git plan via the existing plan/execute model, so -OutputScriptPath yields an auditable script and the destructive-by-default behavior is documented inline for review bots. Design: docs/design-split-bypath.md. Tests: 15 new Pester cases (adds, modifies, deletions, multi-path, empty-source, stacked/flat, explicit destination base, script output, AutoStash, preserve destructive/copy) — full suite green (258). Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- GitSplit.Tests.ps1 | 361 +++++++++++++++++++ GitSplit.psd1 | 1 + GitSplit.psm1 | 685 ++++++++++++++++++++++++++++++++++++ docs/design-split-bypath.md | 508 ++++++++++++++++++++++++++ 4 files changed, 1555 insertions(+) create mode 100644 docs/design-split-bypath.md diff --git a/GitSplit.Tests.ps1 b/GitSplit.Tests.ps1 index daa1276..40222f4 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,364 @@ 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 + } + } + + Context "preserve mode (-Squash:`$false, git-filter-repo)" { + BeforeEach { + $script:FilterRepoAvailable = $null -ne (Get-Command git-filter-repo -ErrorAction SilentlyContinue) + } + + It "rewrites the source keeping commit structure minus the extracted paths (destructive)" { + if (-not $script:FilterRepoAvailable) { + Set-ItResult -Skipped -Because "git-filter-repo is not installed; preserve mode requires it." + } + 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() + # filter-repo rewrites the whole history (it strips a.txt from every commit, including the + # base), so the old $baseCommit SHA is no longer an ancestor of the rewritten tip. Resolve the + # REWRITTEN base from the new tip and compare within the rewritten history. + $newSourceTip | Should -Not -Be $originalHead + $rewrittenBase = (git rev-parse "$newSourceTip~$originalCommitCount").Trim() + # filter-repo preserves commit structure (no squash): the in-range commit count is unchanged. + $newCommitCount = [int](git rev-list --count "$rewrittenBase..$newSourceTip").Trim() + $newCommitCount | Should -Be $originalCommitCount + # a.txt change excised from the source range (rewritten-base .. rewritten-tip). + (git diff --name-only $rewrittenBase $newSourceTip) | Should -Not -Contain 'a.txt' + # Destination is stacked on the rewritten source and carries a.txt. + (git rev-parse 'sbp-preserve^').Trim() | Should -Be $newSourceTip + (git diff --name-only $newSourceTip 'sbp-preserve') | Should -Be 'a.txt' + } + finally { + Pop-Location + } + } + + It "leaves the source untouched in copy mode (preserve)" { + if (-not $script:FilterRepoAvailable) { + Set-ItResult -Skipped -Because "git-filter-repo is not installed; preserve mode requires it." + } + 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..98d9a27 100644 --- a/GitSplit.psm1 +++ b/GitSplit.psm1 @@ -4597,6 +4597,690 @@ 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): `git filter-repo --invert-paths` in a temp clone, fetch-back. Keeps + the source's commit structure with the paths excised from each commit. Requires git-filter-repo. + + 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. + + 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. The plan prints the old->new mapping on completion; 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 + } + } + + # --- preserve mode requires git-filter-repo --- + if (-not $squash) { + if (-not (Get-Command git-filter-repo -ErrorAction SilentlyContinue)) { + throw "Split-ByPath preserve mode (-Squash:`$false) requires git-filter-repo, which was not found on PATH. Install with: brew install git-filter-repo (or: pip install git-filter-repo)" + } + } + + # --- 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' + $plannedClonePath = if (-not $squash) { New-GitSplitTempDirectoryPath -Prefix 'gitsplit-splitbypath-clone' } else { $null } + $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) + '$clonePath = ' + (ConvertTo-PowerShellStringLiteral $plannedClonePath) + '$disabledHooksPath = ' + (ConvertTo-PowerShellStringLiteral $plannedDisabledHooksPath) + '$stashed = $false' + '$stashName = $null' + '$worktreeCreated = $false' + '$cloneCreated = $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' + ) + + if ($squash) { + $execLines += @( + '# --- squash mode: pure git, commit-from-index in a temp worktree ---' + 'if (Test-Path -LiteralPath $worktreePath) { throw "Planned worktree path ''$worktreePath'' already exists." }' + 'try {' + ' 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' + ' }' + '' + ' if ($stacked) {' + ' # Stacked: dest parented on the rewritten source tip. Stage the extract paths and commit on top.' + ' & git @longPathGitArgs -C $worktreePath add -A -- @paths 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git add -A -- failed" }' + ' & git @longPathGitArgs -C $worktreePath -c "core.hooksPath=$disabledHooksPath" commit -m $destinationMessage --quiet 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git commit (destination) failed" }' + ' $destSha = (& git -C $worktreePath rev-parse HEAD).Trim()' + ' }' + ' else {' + ' # Flat: dest parented on $destinationBaseCommit (== $baseCommit unless -DestinationBase given).' + ' # Build the destination commit directly on $destBase via commit-from-index: soft-reset HEAD to' + ' # $destBase (index keeps the staged tree), stage the extract paths from the working tree (which' + ' # still holds the HEAD versions), and commit only those paths. This avoids cherry-pick' + ' # reparenting, which would conflict (modify/delete) whenever $destBase lacks an extracted path' + ' # that existed at the base -- e.g. an explicit destination base, where the path is an add.' + ' $destBase = if ($destinationBaseCommit) { $destinationBaseCommit } else { $baseCommit }' + ' & git @longPathGitArgs -C $worktreePath -c "core.hooksPath=$disabledHooksPath" reset --soft $destBase 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git reset --soft (flat) failed" }' + ' & git @longPathGitArgs -C $worktreePath add -A -- @paths 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git add -A -- (flat) failed" }' + ' & git @longPathGitArgs -C $worktreePath -c "core.hooksPath=$disabledHooksPath" commit -m $destinationMessage --quiet -- @paths 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git commit (paths) failed" }' + ' $destSha = (& git -C $worktreePath rev-parse HEAD).Trim()' + ' }' + ' if ($LASTEXITCODE -ne 0 -or $destSha -notmatch ''^[0-9a-f]{40}$'') { throw "Failed to resolve destination commit." }' + ) + } + else { + # preserve mode: git filter-repo in a fresh clone, fetch-back + $execLines += @( + '# --- preserve mode: git filter-repo in a fresh --no-hardlinks clone, then fetch-back ---' + '# The source rewrite (filter-repo) only runs in destructive mode; copy mode skips it (sourceTip unused).' + 'if (Test-Path -LiteralPath $clonePath) { throw "Planned clone path ''$clonePath'' already exists." }' + 'try {' + ' if ($removeFromSource) {' + ' & git clone --local --no-hardlinks $expectedRepoRoot $clonePath 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git clone --local --no-hardlinks failed" }' + ' $cloneCreated = $true' + ' & git -C $clonePath checkout -q $expectedBranch 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git checkout $expectedBranch in clone failed" }' + ' $pruneArg = if ($keepEmpty) { "off" } else { "auto" }' + ' $filterArgs = @("--force", "--invert-paths", "--prune-empty=$pruneArg")' + ' foreach ($p in $paths) { $filterArgs += @("--path", $p) }' + ' & git -C $clonePath filter-repo @filterArgs 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git filter-repo failed" }' + ' & git fetch $clonePath $expectedBranch --force 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git fetch (rewrite back) failed" }' + ' $sourceTip = (& git rev-parse FETCH_HEAD).Trim()' + ' if ($LASTEXITCODE -ne 0 -or $sourceTip -notmatch ''^[0-9a-f]{40}$'') { throw "Failed to resolve rewritten source tip." }' + ' }' + '' + ' # Build the destination commit (paths net change) directly on $destBase via commit-from-index:' + ' # soft-reset HEAD to $destBase (index keeps the expectedHead tree), stage the extract paths from' + ' # the working tree, and commit only those paths. The commit''s parent IS $destBase, so no' + ' # cherry-pick reparenting is needed -- which would otherwise conflict (modify/delete) whenever' + ' # $destBase lacks an extracted path that existed at the base. In stacked destructive mode' + ' # $destBase is the filter-repo-rewritten $sourceTip (paths already excised); the destination' + ' # reapplies the path net-change on top of it, so the PR diff shows only the extracted paths.' + ' 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 (dest) failed" }' + ' $worktreeCreated = $true' + ' $destBase = if ($stacked) { $sourceTip } elseif ($destinationBaseCommit) { $destinationBaseCommit } else { $baseCommit }' + ' & git @longPathGitArgs -C $worktreePath -c "core.hooksPath=$disabledHooksPath" reset --soft $destBase 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git reset --soft (dest) failed" }' + ' & git @longPathGitArgs -C $worktreePath add -A -- @paths 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git add -A -- (dest) failed" }' + ' & git @longPathGitArgs -C $worktreePath -c "core.hooksPath=$disabledHooksPath" commit -m $destinationMessage --quiet -- @paths 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }' + ' if ($LASTEXITCODE -ne 0) { throw "git commit (dest paths) failed" }' + ' $destSha = (& git -C $worktreePath rev-parse HEAD).Trim()' + ' if ($LASTEXITCODE -ne 0 -or $destSha -notmatch ''^[0-9a-f]{40}$'') { throw "Failed to resolve 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."' + ' Write-Host " source: $expectedBranch -> $(if ($removeFromSource) { $sourceTip } else { $expectedHead + '' (unchanged)'' })"' + ' Write-Host " destination: $destinationBranch -> $destSha"' + ' Write-Host " (History rewrite changes commit SHAs; old SHAs cited in review threads may become dangling on GitHub after force-push.)"' + '}' + 'finally {' + ) + + if (-not $squash) { + $execLines += @( + ' if ($cloneCreated -and $clonePath -and (Test-Path -LiteralPath $clonePath)) {' + ' Remove-Item -LiteralPath $clonePath -Recurse -Force -ErrorAction SilentlyContinue' + ' }' + ) + } + $execLines += @( + ' 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 @( + 'Execute the split in an isolated worktree (squash) or fresh clone (preserve), then update refs in the main repo.', + 'Cleanup removes the temporary worktree/clone 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 (requires git-filter-repo). + + .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 after excision (--prune-empty=off). + + .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 +5304,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..7decee6 --- /dev/null +++ b/docs/design-split-bypath.md @@ -0,0 +1,508 @@ +# 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) + [-CreateDestinationBranch] # create dest; requires DestinationBase (or default) + [-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, paths excised from each | ONE commit (only paths' net change) | `git filter-repo --invert-paths` in a temp clone, fetch-back | source: every commit from first-touch onward (cascade) | + +`-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. + +`-CreateDestinationBranch` mirrors `Move-Commit`: required when the destination +does not already exist; throws with delete-hints if it exists and +`-CreateDestinationBranch` is set (consistency with `New-MoveCommitPlan`'s +existing guard). + +## 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 (`git filter-repo`) + +Excising paths from every commit in a range while **preserving commit structure** +is a per-commit history rewrite — the operation `git filter-repo` exists for. +Native porcelain (`rebase --onto` with `exec git rm`, or a cherry-pick replay) +is fiddly with adds/deletes/renames and silently produces empty commits; filter-repo +handles all of those correctly and prunes empties. This is the one mode where an +external tool earns its place. + +**Dependency & isolation constraints (the non-obvious parts):** + +1. **`filter-repo` refuses non-fresh-clone repos.** It rejects a repo that isn't a + freshly-made clone (guard against corrupting a working repo). A `git worktree` + shares the main repo's object store, so it is *not* a fresh clone and will be + rejected. Therefore preserve mode cannot reuse GitSplit's temp-worktree + pattern. Instead: `git clone --local --no-hardlinks ` of just the + source branch into `New-GitSplitTempDirectoryPath`, run filter-repo there with + `--force`, then **fetch the rewritten tip back** into the main repo and + force-update the source ref. The `--no-hardlinks` matters: a hardlinked clone + shares objects, and filter-repo's rewrite + GC must not touch the source repo's + object store. +2. **`filter-repo` strips the `origin` remote** (safety: prevent accidental push of + rewritten history). This is fine here because we operate in the temp clone and + only fetch-back a branch — we never push from the clone. +3. **Command:** + ```bash + git clone --local --no-hardlinks "$repoRoot" "$tempClone" + cd "$tempClone" + git filter-repo --force --invert-paths \ + --path "$p1" --path "$p2" \ + --prune-empty $( $KeepEmpty ? 'off' : 'auto' ) + # fetch the rewritten source branch back into the main repo + cd "$repoRoot" + git fetch "$tempClone" "$sourceBranch" --force + git update-ref "refs/heads/$sourceBranch" FETCH_HEAD + ``` +4. **Destination (preserve, both stacked & flat):** built commit-from-index off + `DestinationBase` — `git checkout "$DestinationBase"`, then apply paths' net + change. Because the dest is a single new commit (not a per-commit rewrite), the + net change is applied from a `git diff BaseRef..HEAD -- ` patch OR by + the same index technique: in a temp worktree at `HEAD`, `git reset --soft + BaseRef`, `git commit -- ` to capture the net change, then + `git branch -f` the dest at that commit reparented onto `DestinationBase` via + `git cherry-pick`. Prefer the index technique for the same deletion-correctness + reason as §5.1. + +**`filter-repo` not installed:** detect at plan-build time +(`Get-Command git-filter-repo`), throw with an install hint, and document the +native cherry-pick-replay as a future dependency-free fallback (see §10). Do +**not** silently fall back — fail loud (maintainer's standing rule). + +## 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; + filter-repo in preserve mode simply has nothing to remove from those commits). + 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. filter-repo follows renames for its own + path filters, so preserve mode is more robust here — note the asymmetry. + +### 7.3 Empty-commit pruning (preserve only) +- filter-repo `--prune-empty=auto` (default) drops commits that become empty after + excision. This **changes the commit count**, which surprises anyone diffing + before/after. Default to `auto`; `-KeepEmpty` passes `--prune-empty=off` to + preserve empty commits as markers. Document that SHA count may shrink. (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). Recoverable + *locally* via filter-repo's `.git/filter-repo/` old→new map, but not on GitHub. + 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 should emit the old→new SHA map to the console on + completion 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:** reuse `New-MoveCommitPlan`'s + `Get-MoveCommitMissingDestinationBranchMessage`-style throw with delete-hints + when `-CreateDestinationBranch` is set against an existing ref. + +### 7.6 filter-repo-specific (preserve only) +- **Not installed:** `Get-Command git-filter-repo` at plan-build; throw with + install hint (`brew install git-filter-repo` / `pip install git-filter-repo`). +- **Fresh-clone friction:** the `--local --no-hardlinks` temp clone + fetch-back + is mandatory (§6.1); a worktree will be rejected by filter-repo. Tests must + cover the clone→filter→fetch→update-ref round-trip. +- **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()] [switch] $CreateDestinationBranch, + [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 clone+filter-repo+fetch-back. + # 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) / temp clone (preserve) 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 + CreateDestinationBranch | squash | throws with delete-hints | +| 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: filter-repo absent | preserve | throws with install hint | +| preserve: structure retained | preserve | source commit count preserved (minus pruned empties); paths gone from each commit tree | +| preserve: KeepEmpty | preserve | empty commits kept (`--prune-empty=off`) | +| preserve: path deleted in range | preserve | filter-repo restores the file in earlier commits; dest records deletion | + +filter-repo cases are skipped (not failed) if `git-filter-repo` isn't on PATH in +CI — gate with a `BeforeAll` `Get-Command` check so the squash suite still runs +green on runners without filter-repo. + +## 10. Future / out of scope + +- **Dependency-free preserve mode:** a native cherry-pick-replay (`git cherry-pick + -n ; git restore --staged --worktree -- ; git checkout ^ -- ; + commit -C or skip if empty`) would remove the filter-repo dependency. It's + more code and slower, and the add/delete/rename edge cases are exactly what + filter-repo gets right for free. Recommended as a later `-PreserveEngine Native` + option, not the first cut. +- **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. **filter-repo dependency:** accepted for preserve mode in the first cut; the + native engine (§10) is a later option. filter-repo absent → fail loud with an + install hint (preserve mode unavailable, squash mode unaffected). +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.