Skip to content

feature: add Split-ByPath — range-aware path extraction into a separate PR - #23

Open
dkattan wants to merge 1 commit into
mainfrom
feature/split-bypath
Open

feature: add Split-ByPath — range-aware path extraction into a separate PR#23
dkattan wants to merge 1 commit into
mainfrom
feature/split-bypath

Conversation

@dkattan

@dkattan dkattan commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Split-ByPath is the range-aware counterpart to Split-Commit / Move-Commit. Every existing GitSplit verb operates on a single commit; none can express "one file's changes are interleaved across many commits in a branch, and I want them as a single separate PR." Split-ByPath does exactly that: it extracts the net change to a set of paths across BaseRef..HEAD into a new destination branch, and (destructive by default, mirroring mv) rewrites the current branch to drop those paths' changes.

This came up concretely when splitting a workflow file out of a 21-commit driver branch — the resolution was done by hand with git reset --soft <base> + git restore --staged <paths>. This PR captures that as a first-class verb.

📋 Full design & rationale: docs/design-split-bypath.md

Modes

  • -Squash (default) — pure git, commit-from-index. git reset --soft BaseRef stages the entire range; commit the remainder, then stage + commit the extract paths. 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 --local --no-hardlinks clone, fetched back. Keeps the source's commit structure with the paths excised from each commit. Requires git-filter-repo.

Destination placement

  • Stacked (default when destructive): destination parented on the rewritten source tip, so its PR base is the source branch and the diff shows only the extracted paths.
  • Flat (-DestinationBase <ref>, or copy mode): destination parented on -DestinationBase (or BaseRef) as an independent sibling.

Design decisions (per maintainer)

  • No -TipRef — the tip is always the current branch HEAD. A free tip would make the destructive default ambiguous about which branch it destroys.
  • Destructive by default (-RemoveFromSource defaults $true), like mv; opt out with -RemoveFromSource:$false. Documented inline for review bots.
  • -Squash defaults $true (commit-from-index pure git); preserve mode is the opt-in.
  • Out of scope: build/test guards. GitSplit handles tedious mechanical git operations; buildability is the caller's concern.
  • The builder (New-SplitByPathPlan) emits a reviewable pure-git plan via the existing plan/execute model — -OutputScriptPath yields an auditable script, so the destructive behavior is reviewable before execution.

Examples

# Extract ci.yml's net change into ci-split (stacked on the rewritten source), remove from current branch
Split-ByPath -Path '.github/workflows/ci.yml' -DestinationBranch 'ci-split'

# Copy mode: source untouched, flat dest on main with the two files' net change
Split-ByPath -Path 'src/a.ts','src/b.ts' -DestinationBranch 'feat-ts' -DestinationBase 'main' -RemoveFromSource:$false

Tests

15 new Pester cases covering: detached-HEAD throw, BaseRef-not-ancestor, destination-exists, no-path-changes, BaseRef==HEAD, stacked destructive squash, flat copy squash, explicit destination base, deletion extraction, multi-path (empty-source collapse), reviewable script output, AutoStash, dirty-tree refusal, and preserve mode (destructive + copy). Full suite: 258 passing, PSScriptAnalyzer clean against the two CI rules.

Generated with Claude Code
via Happy

Co-Authored-By: Claude noreply@anthropic.com
Co-Authored-By: Happy yesreply@happy.engineering

…te PR

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 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
Copilot AI balanced review requested due to automatic review settings August 4, 2026 13:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds range-aware path extraction to GitSplit, creating a destination branch while optionally rewriting the source branch.

Changes:

  • Adds squash and history-preserving extraction modes.
  • Exports the new Split-ByPath cmdlet.
  • Adds Pester coverage and design documentation.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
GitSplit.psm1 Implements planning and execution.
GitSplit.psd1 Exports the cmdlet.
GitSplit.Tests.ps1 Adds behavioral tests.
docs/design-split-bypath.md Documents the design and semantics.
Suppressed comments (2)

GitSplit.psm1:4979

  • Preserve mode is not range-aware here: without a --refs/range restriction, filter-repo removes these paths from every ancestor, including BaseRef. A file that existed at the base therefore disappears from the rewritten source instead of reverting to its base version; for a deletion at HEAD, the subsequent stacked destination commit has no path change at all and fails as empty. Restrict the rewrite to BaseRef..$expectedBranch (or replay only that range) while retaining the original base as the parent.
      '    $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 }'

GitSplit.psm1:5004

  • The preserve-mode destination repeats the explicit-base bug from squash mode: resetting to an ancestor and then staging the working tree records the full DestinationBase..HEAD path state, rather than only the requested BaseRef..HEAD net change. Apply the frozen range delta to $destBase instead of copying the HEAD snapshot.
      '  & 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 -- <paths> (dest) failed" }'
      '  & git @longPathGitArgs -C $worktreePath -c "core.hooksPath=$disabledHooksPath" commit -m $destinationMessage --quiet -- @paths 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }'

Comment thread GitSplit.psm1
Comment on lines +5057 to +5060
' 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.)"'
Comment thread GitSplit.psm1
Comment on lines +4952 to +4956
' & 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 -- <paths> (flat) failed" }'
' & git @longPathGitArgs -C $worktreePath -c "core.hooksPath=$disabledHooksPath" commit -m $destinationMessage --quiet -- @paths 2>&1 | ForEach-Object { $_ | Out-String | Write-Host }'
-Path <string[]> # paths to extract (repo-relative; matched at current HEAD names)
-DestinationBranch <string> # mandatory
[-BaseRef <string>] # default: merge-base(HEAD, origin/HEAD)
[-CreateDestinationBranch] # create dest; requires DestinationBase (or default)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants