Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions modules/Shmuelie.Git/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ Versions change only when a release is cut; unreleased work stays under

## [Unreleased]

### Added
- `New-Worktree` and `Add-Worktree` now accept `-WorktreePath` to place the
created worktree at an explicit destination while preserving the conventional
sibling path when omitted.

### Fixed
- `Update-Worktrees` now reports behind worktrees with in-progress git
operations as `InProgress` and skips stash/fast-forward handling so rebases,
Expand Down
103 changes: 93 additions & 10 deletions modules/Shmuelie.Git/Public/Worktrees.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -202,31 +202,97 @@ function Get-WorktreePath {
Join-Path $container $BranchName
}

function Invoke-GitWorktreeAdd {
<#
.SYNOPSIS
Run git worktree add and surface git's error output.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string[]]$Arguments,

[Parameter(Mandatory)]
[string]$FailureContext
)

$output = & git @Arguments 2>&1
$exitCode = $LASTEXITCODE
if ($exitCode -eq 0) {
return $true
}

$message = ($output | ForEach-Object { $_.ToString() } | Where-Object { $_ }) -join [Environment]::NewLine
if (-not $message) {
$message = 'No output.'
}

Write-Error "git worktree add failed for $FailureContext (exit $exitCode): $message"
$false
}

function Resolve-CreatedWorktreePath {
<#
.SYNOPSIS
Resolve a newly-created worktree path for Set-Location.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Path
)

$resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue | Select-Object -First 1
if ($resolved) {
return $resolved.ProviderPath
}

[IO.Path]::GetFullPath($Path)
}

function Add-Worktree {
<#
.SYNOPSIS
Checkout an existing branch to a worktree
.PARAMETER BranchName
Name of the branch
.PARAMETER WorktreePath
Optional destination path for the new worktree. When omitted, the path is
derived from the repository container and branch name.
.PARAMETER SetLocation
Whether to change the current directory to the new worktree
.EXAMPLE
Add-Worktree -BranchName feature/my-feature -SetLocation
Checks out the existing branch to a new worktree and navigates to it.
.EXAMPLE
Add-Worktree -BranchName feature/my-feature -WorktreePath ../custom-feature
Checks out the existing branch to the supplied worktree path.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$BranchName,

[ValidateNotNullOrEmpty()]
[string]$WorktreePath,

[switch]$SetLocation = $false
)
process {
$worktreePath = Get-WorktreePath -BranchName $BranchName
if ($PSCmdlet.ShouldProcess($worktreePath, "Add worktree for branch '$BranchName'")) {
git worktree add $worktreePath $BranchName
if (($LASTEXITCODE -eq 0) -and $SetLocation) {
Set-Location -Path $worktreePath
$resolvedWorktreePath = if ($PSBoundParameters.ContainsKey('WorktreePath')) {
$WorktreePath
} else {
Get-WorktreePath -BranchName $BranchName
}
if (-not $resolvedWorktreePath) { return }

if ($PSCmdlet.ShouldProcess($resolvedWorktreePath, "Add worktree for branch '$BranchName'")) {
$created = Invoke-GitWorktreeAdd `
-Arguments @('worktree', 'add', $resolvedWorktreePath, $BranchName) `
-FailureContext "branch '$BranchName' at '$resolvedWorktreePath'"
if ($created -and $SetLocation) {
Set-Location -LiteralPath (Resolve-CreatedWorktreePath -Path $resolvedWorktreePath)
}
}
}
Expand Down Expand Up @@ -271,6 +337,9 @@ function New-Worktree {
.PARAMETER NoPrefix
Use WorkName as the branch name verbatim, without the kind prefix
(e.g. checking out an existing branch like 'main' or 'master').
.PARAMETER WorktreePath
Optional destination path for the new worktree. When omitted, the path is
derived from the repository container and branch name.
.PARAMETER SetLocation
Whether to change the current directory to the new worktree.
.EXAMPLE
Expand All @@ -285,6 +354,9 @@ function New-Worktree {
.EXAMPLE
New-Worktree -WorkName main -NoPrefix -SetLocation
Creates a worktree for a branch named exactly 'main' with no kind prefix.
.EXAMPLE
New-Worktree -WorkName my-feature -WorktreePath ../custom-feature
Creates branch user/<user>/my-feature in the supplied worktree path.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
Expand All @@ -300,6 +372,9 @@ function New-Worktree {

[switch]$NoPrefix,

[ValidateNotNullOrEmpty()]
[string]$WorktreePath,

[switch]$SetLocation = $false
)
process {
Expand All @@ -315,11 +390,19 @@ function New-Worktree {
'release' { "release/$WorkName" }
}
}
$worktreePath = Get-WorktreePath -BranchName $branchName
if ($PSCmdlet.ShouldProcess($worktreePath, "Create worktree for new branch '$branchName'")) {
git worktree add -b $branchName $worktreePath
if (($LASTEXITCODE -eq 0) -and $SetLocation) {
Set-Location -Path $worktreePath
$resolvedWorktreePath = if ($PSBoundParameters.ContainsKey('WorktreePath')) {
$WorktreePath
} else {
Get-WorktreePath -BranchName $branchName
}
if (-not $resolvedWorktreePath) { return }

if ($PSCmdlet.ShouldProcess($resolvedWorktreePath, "Create worktree for new branch '$branchName'")) {
$created = Invoke-GitWorktreeAdd `
-Arguments @('worktree', 'add', '-b', $branchName, $resolvedWorktreePath) `
-FailureContext "new branch '$branchName' at '$resolvedWorktreePath'"
if ($created -and $SetLocation) {
Set-Location -LiteralPath (Resolve-CreatedWorktreePath -Path $resolvedWorktreePath)
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions modules/Shmuelie.Git/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ Import-Module Shmuelie.Git
| `Get-Worktrees` | List worktrees for the current repository |
| `Get-CurrentWorktree` / `Get-RootWorktree` | Resolve the worktree for the current directory or the repository root |
| `Get-WorktreePath` | Compute the path a branch's worktree would use |
| `New-Worktree` | Create a branch and check it out to a worktree |
| `Add-Worktree` | Check out an existing branch to a worktree |
| `New-Worktree` | Create a branch and check it out to a worktree, optionally at `-WorktreePath` |
| `Add-Worktree` | Check out an existing branch to a worktree, optionally at `-WorktreePath` |
| `Remove-Worktree` | Remove a worktree by branch name or path (optionally deleting its branch) |
| `Set-Worktree` | Switch to a worktree by branch name or path |
| `Remove-StaleWorktree` | Prune stale worktree administrative entries for deleted worktree directories |
Expand Down Expand Up @@ -53,6 +53,7 @@ surfaces `user/alex/wim-work`.
```powershell
New-Repository https://github.com/owner/repo
New-Worktree -WorkName my-feature -SetLocation
Add-Worktree -BranchName feature/my-feature -WorktreePath ../custom-feature
Update-Worktrees | Where-Object Status -ne Current
Find-StaleBranch | Remove-Worktree
Get-GitStatusSummary
Expand Down
129 changes: 128 additions & 1 deletion tests/Shmuelie.Git.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,74 @@ Describe 'Add-Worktree' {
}
}

Describe 'Add-Worktree creation' -Skip:(-not (Get-Command git -ErrorAction SilentlyContinue)) {
It 'checks out an existing branch to an explicit worktree path' {
$repo = New-TestRepo -Path (Join-Path $TestDrive 'add-explicit-main')
$branch = 'feature/add-explicit'
$customPath = Join-Path $TestDrive 'custom-add-explicit'
Invoke-Git @('-C', $repo, 'branch', $branch)

Push-Location $repo
try {
Add-Worktree -BranchName $branch -WorktreePath $customPath -Confirm:$false
Test-Path -LiteralPath $customPath | Should -BeTrue
(@(Get-Worktrees) | Where-Object Branch -eq $branch).Path |
Should -BeExactly (Resolve-Path -LiteralPath $customPath).Path
} finally {
Pop-Location
}
}

It 'keeps the auto-generated location when no worktree path is supplied' {
$repo = New-TestRepo -Path (Join-Path $TestDrive 'add-auto-main')
$branch = 'feature/add-auto'
$expectedPath = Join-Path (Split-Path $repo -Parent) $branch
Invoke-Git @('-C', $repo, 'branch', $branch)

Push-Location $repo
try {
Add-Worktree -BranchName $branch -Confirm:$false
Test-Path -LiteralPath $expectedPath | Should -BeTrue
(@(Get-Worktrees) | Where-Object Branch -eq $branch).Path |
Should -BeExactly (Resolve-Path -LiteralPath $expectedPath).Path
} finally {
Pop-Location
}
}

It 'changes location to the resolved explicit worktree path when SetLocation is used' {
$repo = New-TestRepo -Path (Join-Path $TestDrive 'add-setlocation-main')
$branch = 'feature/add-setlocation'
$customPath = Join-Path $TestDrive 'custom-add-setlocation'
Invoke-Git @('-C', $repo, 'branch', $branch)

Push-Location $repo
try {
Add-Worktree -BranchName $branch -WorktreePath $customPath -SetLocation -Confirm:$false
(Get-Location).Path | Should -BeExactly (Resolve-Path -LiteralPath $customPath).Path
} finally {
Pop-Location
}
}

It 'surfaces git errors when the destination path is invalid' {
$repo = New-TestRepo -Path (Join-Path $TestDrive 'add-failure-main')
$branch = 'feature/add-failure'
$existingPath = Join-Path $TestDrive 'existing-add-destination'
New-Item -ItemType Directory -Path $existingPath -Force | Out-Null
Set-Content -Path (Join-Path $existingPath 'already-here.txt') -Value 'content'
Invoke-Git @('-C', $repo, 'branch', $branch)

Push-Location $repo
try {
{ Add-Worktree -BranchName $branch -WorktreePath $existingPath -Confirm:$false -ErrorAction Stop } |
Should -Throw -ExpectedMessage '*git worktree add failed*already exists*'
} finally {
Pop-Location
}
}
}

Describe 'Get-GitStatusSummary' {
It 'does not pop the caller location stack when -Path cannot be pushed' {
$startingPath = (Get-Location).Path
Expand Down Expand Up @@ -1841,7 +1909,14 @@ Describe 'New-Worktree' -Skip:(-not (Get-Command git -ErrorAction SilentlyContin

Push-Location $repo
try {
New-Worktree -WorkName 'dry-run' -UserName 'tester' -WhatIf -Confirm:$false
$transcriptPath = Join-Path $TestDrive 'new-worktree-auto-whatif.txt'
Start-Transcript -Path $transcriptPath -Force | Out-Null
try {
New-Worktree -WorkName 'dry-run' -UserName 'tester' -WhatIf -Confirm:$false
} finally {
Stop-Transcript | Out-Null
}
(Get-Content -LiteralPath $transcriptPath -Raw) | Should -Match ([regex]::Escape($expectedPath))
Test-Path -LiteralPath $expectedPath | Should -BeFalse
Invoke-Git @('-C', $repo, 'branch', '--list', $branchName) | Should -BeNullOrEmpty
} finally {
Expand All @@ -1865,6 +1940,58 @@ Describe 'New-Worktree' -Skip:(-not (Get-Command git -ErrorAction SilentlyContin
}
}

It 'creates a new branch and worktree at an explicit worktree path' {
$repo = New-TestRepo -Path (Join-Path $TestDrive 'new-worktree-explicit-main')
$branchName = 'user/tester/explicit-path'
$customPath = Join-Path $TestDrive 'custom-new-explicit'

Push-Location $repo
try {
New-Worktree -WorkName 'explicit-path' -UserName 'tester' -WorktreePath $customPath -Confirm:$false
Test-Path -LiteralPath $customPath | Should -BeTrue
Invoke-Git @('-C', $repo, 'rev-parse', '--verify', $branchName) | Should -Not -BeNullOrEmpty
(@(Get-Worktrees) | Where-Object Branch -eq $branchName).Path |
Should -BeExactly (Resolve-Path -LiteralPath $customPath).Path
} finally {
Pop-Location
}
}

It 'changes location to the resolved explicit worktree path for a new branch when SetLocation is used' {
$repo = New-TestRepo -Path (Join-Path $TestDrive 'new-worktree-setlocation-main')
$customPath = Join-Path $TestDrive 'custom-new-setlocation'

Push-Location $repo
try {
New-Worktree -WorkName 'setlocation-path' -UserName 'tester' -WorktreePath $customPath -SetLocation -Confirm:$false
(Get-Location).Path | Should -BeExactly (Resolve-Path -LiteralPath $customPath).Path
} finally {
Pop-Location
}
}

It 'reports an explicit worktree path in WhatIf output and creates nothing' {
$repo = New-TestRepo -Path (Join-Path $TestDrive 'new-worktree-explicit-whatif-main')
$branchName = 'user/tester/explicit-dry-run'
$customPath = Join-Path $TestDrive 'custom-new-explicit-whatif'

Push-Location $repo
try {
$transcriptPath = Join-Path $TestDrive 'new-worktree-explicit-whatif.txt'
Start-Transcript -Path $transcriptPath -Force | Out-Null
try {
New-Worktree -WorkName 'explicit-dry-run' -UserName 'tester' -WorktreePath $customPath -WhatIf -Confirm:$false
} finally {
Stop-Transcript | Out-Null
}
(Get-Content -LiteralPath $transcriptPath -Raw) | Should -Match ([regex]::Escape($customPath))
Test-Path -LiteralPath $customPath | Should -BeFalse
Invoke-Git @('-C', $repo, 'branch', '--list', $branchName) | Should -BeNullOrEmpty
} finally {
Pop-Location
}
}

It 'creates an unprefixed branch and worktree when NoPrefix is used' {
$repo = New-TestRepo -Path (Join-Path $TestDrive 'new-worktree-noprefix-main')
$branchName = 'plain-work'
Expand Down