Worktrees.ps1
|
using module ./Classes/WorktreeSetValuesGenerator.psm1 function Resolve-GitRepositoryPath { <# .SYNOPSIS Resolve and validate a path inside a git working tree. .PARAMETER AllowNonRepository Validate only that the literal path is an existing FileSystem directory. Use for operations that do not require repository context. #> [CmdletBinding()] param( [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('RepositoryPath', 'RepoPath')] [string]$Path, [switch]$AllowBare, [switch]$AllowNonRepository, [hashtable]$Environment ) $candidate = if ($Path) { $Path } else { (Get-Location).ProviderPath } $resolved = Resolve-Path -LiteralPath $candidate -ErrorAction Ignore | Select-Object -First 1 if (-not $resolved) { Write-Error "Git repository path not found: '$candidate'." return } $providerPath = $resolved.ProviderPath if ($resolved.Provider.Name -ne 'FileSystem' -or -not (Test-Path -LiteralPath $providerPath -PathType Container)) { Write-Error "Git repository path must be a FileSystem directory: '$candidate'." return } if ($AllowNonRepository) { return $providerPath } $inside = Invoke-GitProcess -Arguments @('-C', $providerPath, 'rev-parse', '--is-inside-work-tree') -Environment $Environment if ($inside.ExitCode -ne 0 -or $inside.StandardOutput.Trim() -ne 'true') { if ($AllowBare) { $bare = Invoke-GitProcess -Arguments @('-C', $providerPath, 'rev-parse', '--is-bare-repository') -Environment $Environment if ($bare.ExitCode -eq 0 -and $bare.StandardOutput.Trim() -eq 'true') { return $providerPath } } Write-Error "Path '$providerPath' is not inside a git working tree." return } $providerPath } function Get-Worktrees { <# .SYNOPSIS Get all worktrees for the current repository. .DESCRIPTION Parses the output of 'git worktree list --porcelain' and returns objects with Path, Commit, and Branch properties, plus the remaining porcelain state: Bare, Detached, Locked/LockReason, and Prunable/PrunableReason. The boolean state fields are always present (defaulting to $false) and the reason fields default to an empty string. .PARAMETER Path Directory inside the git working tree to inspect. Defaults to the current location. .EXAMPLE Get-Worktrees Returns all worktrees for the current repository. .EXAMPLE Get-Worktrees -Path C:\repos\project Returns all worktrees for the repository containing the specified path. .EXAMPLE Get-Worktrees | Where-Object Prunable Returns worktrees whose working directory is gone and can be pruned. #> [OutputType('Worktree')] [CmdletBinding()] param( [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('RepositoryPath', 'RepoPath')] [string]$Path ) process { $repoPath = Resolve-GitRepositoryPath -Path $Path -AllowBare if (-not $repoPath) { return } $lines = git -C $repoPath worktree list --porcelain $newEntry = { @{ PSTypeName = 'Worktree' Bare = $false Detached = $false Locked = $false LockReason = '' Prunable = $false PrunableReason = '' } } $entry = & $newEntry foreach ($line in $lines) { if ([string]::IsNullOrWhiteSpace($line)) { if ($entry.ContainsKey('Path')) { [PSCustomObject]$entry $entry = & $newEntry } continue } if ($line.StartsWith('worktree ')) { $rawPath = $line -replace '^worktree ' # git lists worktrees whose directory was deleted manually; keep the # git-reported path when it no longer resolves rather than storing $null. $resolved = Resolve-Path -LiteralPath $rawPath -ErrorAction SilentlyContinue $entry['Path'] = if ($resolved) { $resolved.Path } else { $rawPath } } elseif ($line.StartsWith('HEAD ')) { $entry['Commit'] = $line -replace '^HEAD ' } elseif ($line.StartsWith('branch refs/heads/')) { $entry['Branch'] = $line -replace '^branch refs/heads/' } elseif ($line -eq 'detached') { $entry['Branch'] = '(detached)' $entry['Detached'] = $true } elseif ($line -eq 'bare') { $entry['Bare'] = $true } elseif ($line -eq 'locked' -or $line.StartsWith('locked ')) { $entry['Locked'] = $true if ($line.Length -gt 'locked '.Length) { $entry['LockReason'] = $line.Substring('locked '.Length).Trim() } } elseif ($line -eq 'prunable' -or $line.StartsWith('prunable ')) { $entry['Prunable'] = $true if ($line.Length -gt 'prunable '.Length) { $entry['PrunableReason'] = $line.Substring('prunable '.Length).Trim() } } } # Emit the last entry if ($entry.ContainsKey('Path')) { [PSCustomObject]$entry } } } function Test-PathContains { <# .SYNOPSIS Test if a reference path contains a candidate path. .DESCRIPTION Returns true if the candidate path is equal to or a child of the reference path. .PARAMETER ReferencePath The parent path to test against. .PARAMETER CandidatePath The path to check. .EXAMPLE Test-PathContains -ReferencePath 'C:\repos' -CandidatePath 'C:\repos\project' Returns $true because the candidate is a child of the reference. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ReferencePath, [Parameter(Mandatory)] [string]$CandidatePath ) $separator = [IO.Path]::DirectorySeparatorChar $normalizedRef = ([IO.Path]::GetFullPath($ReferencePath)).TrimEnd($separator) $normalizedCand = [IO.Path]::GetFullPath($CandidatePath) $normalizedCand.Equals($normalizedRef, [System.StringComparison]::OrdinalIgnoreCase) -or $normalizedCand.StartsWith("$normalizedRef$separator", [System.StringComparison]::OrdinalIgnoreCase) } function Get-CurrentWorktree { <# .SYNOPSIS Get the worktree that contains the current directory. .DESCRIPTION Returns the worktree whose path is equal to or a parent of the current working directory. .PARAMETER Path Directory inside the git working tree to inspect. Defaults to the current location. .EXAMPLE Get-CurrentWorktree Returns the worktree object for the current location. .EXAMPLE Get-CurrentWorktree -Path C:\repos\project\src Returns the worktree object containing the specified path. #> [CmdletBinding()] param( [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('RepositoryPath', 'RepoPath')] [string]$Path ) process { $currentPath = Resolve-GitRepositoryPath -Path $Path if (-not $currentPath) { return } Get-Worktrees -Path $currentPath | Where-Object { Test-PathContains -ReferencePath $_.Path -CandidatePath $currentPath } } } function Get-RepositoryName { <# .SYNOPSIS Get the name of the current git repository. .DESCRIPTION Extracts the repository name from the origin remote URL, stripping any trailing .git suffix. .PARAMETER Path Directory inside the git working tree to inspect. Defaults to the current location. .EXAMPLE Get-RepositoryName Returns the repository name, e.g. 'MyRepo'. #> [CmdletBinding()] param( [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('RepositoryPath', 'RepoPath')] [string]$Path ) process { $repoPath = Resolve-GitRepositoryPath -Path $Path if (-not $repoPath) { return } git -C $repoPath remote get-url origin | ForEach-Object { $_.SubString($_.LastIndexOf('/') + 1) -replace '\.git$','' } } } function Get-RootWorktree { <# .SYNOPSIS Get the root (main) worktree for the current repository. .DESCRIPTION Resolves Git's common directory from any repository subdirectory, then matches its parent directory against the worktree list. .PARAMETER Path Directory inside the git working tree to inspect. Defaults to the current location. .EXAMPLE Get-RootWorktree Returns the worktree object for the root of the repository. .EXAMPLE Get-RootWorktree -Path C:\repos\project\src Returns the root worktree for the repository containing the specified path. #> [CmdletBinding()] param( [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('RepositoryPath', 'RepoPath')] [string]$Path ) process { $repoPath = Resolve-GitRepositoryPath -Path $Path if (-not $repoPath) { return } $commonDir = git -C $repoPath rev-parse --path-format=absolute --git-common-dir 2>$null if ($LASTEXITCODE -ne 0 -or -not $commonDir) { Write-Error "Path '$repoPath' is not inside a git working tree." return } $rootPath = [IO.Path]::GetFullPath((Split-Path $commonDir -Parent)) Get-Worktrees -Path $repoPath | Where-Object { [IO.Path]::GetFullPath("$($_.Path)").Equals($rootPath, [System.StringComparison]::OrdinalIgnoreCase) } } } function Get-WorktreePath { <# .SYNOPSIS Get the file system path for a worktree by branch name. .DESCRIPTION Constructs the worktree path from the repository container and branch name. .PARAMETER BranchName The branch name to resolve to a worktree path. .PARAMETER Path Directory inside the git working tree to inspect. Defaults to the current location. .EXAMPLE Get-WorktreePath -BranchName feature/my-feature Returns the expected worktree path for the given branch. .EXAMPLE Get-WorktreePath -BranchName feature/my-feature -Path C:\repos\project Returns the expected worktree path for the repository containing the specified path. #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)] [string]$BranchName, [Parameter(Position = 1, ValueFromPipelineByPropertyName)] [Alias('RepositoryPath', 'RepoPath')] [string]$Path ) process { $repoPath = Resolve-GitRepositoryPath -Path $Path if (-not $repoPath) { return } $root = Get-RootWorktree -Path $repoPath | Select-Object -First 1 if (-not $root) { return } $separator = [IO.Path]::DirectorySeparatorChar $rootPath = [IO.Path]::GetFullPath("$($root.Path)").TrimEnd($separator) $branchPath = ($root.Branch -replace '[/\\]', $separator).Trim($separator) $branchSuffix = "$separator$branchPath" $container = if ($rootPath.EndsWith($branchSuffix, [System.StringComparison]::OrdinalIgnoreCase)) { $rootPath.Substring(0, $rootPath.Length - $branchSuffix.Length) } else { Split-Path $rootPath -Parent } 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, [string]$RepositoryPath ) $result = Invoke-Git -Path $RepositoryPath -Arguments $Arguments -AllowNonZeroExit if (-not $result) { return $false } $output = $result.Output $exitCode = $result.ExitCode 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, [string]$BasePath ) $candidate = if ($BasePath -and -not [IO.Path]::IsPathRooted($Path)) { Join-Path $BasePath $Path } else { $Path } $resolved = Resolve-Path -LiteralPath $candidate -ErrorAction SilentlyContinue | Select-Object -First 1 if ($resolved) { return $resolved.ProviderPath } [IO.Path]::GetFullPath($candidate) } function Add-Worktree { <# .SYNOPSIS Check out an existing branch to a worktree and navigate to it. .DESCRIPTION Checks out an existing branch to a worktree and changes location after successful creation, like New-Repository. Use -NoSetLocation to keep the caller's current directory. Failed creation and -WhatIf never change location. .PARAMETER BranchName Name of the branch. .PARAMETER Path Directory inside the git working tree to add the worktree from. Defaults to the current location. .PARAMETER WorktreePath Optional destination path for the new worktree. When omitted, the path is derived from the repository container and branch name. .PARAMETER SetLocation Temporary compatibility switch. -SetLocation still navigates to the new worktree; -SetLocation:$false preserves the current directory. Prefer omitting this switch to navigate, or use -NoSetLocation to stay. Cannot be combined with -NoSetLocation, even when either switch is explicitly false. .PARAMETER NoSetLocation Do not change to the created worktree. Cannot be combined with -SetLocation. .EXAMPLE Add-Worktree -BranchName feature/my-feature Checks out the existing branch to a new worktree and navigates to it. .EXAMPLE Add-Worktree -BranchName feature/my-feature -NoSetLocation Checks out the existing branch without changing the current directory. .EXAMPLE Add-Worktree -Path C:\repos\project -BranchName feature/my-feature -WorktreePath ../custom-feature Checks out the existing branch from the specified repository to the supplied worktree path and navigates there. Relative destinations are resolved from -Path. #> [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Default')] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$BranchName, [Parameter(ValueFromPipelineByPropertyName)] [Alias('RepositoryPath', 'RepoPath')] [string]$Path, [ValidateNotNullOrEmpty()] [string]$WorktreePath, [Parameter(ParameterSetName = 'SetLocation')] [switch]$SetLocation, [Parameter(ParameterSetName = 'Default')] [switch]$NoSetLocation ) process { $repoPath = Resolve-GitRepositoryPath -Path $Path if (-not $repoPath) { return } $resolvedWorktreePath = if ($PSBoundParameters.ContainsKey('WorktreePath')) { $WorktreePath } else { Get-WorktreePath -BranchName $BranchName -Path $repoPath } if (-not $resolvedWorktreePath) { return } if ($PSCmdlet.ShouldProcess($resolvedWorktreePath, "Add worktree for branch '$BranchName'")) { $created = Invoke-GitWorktreeAdd ` -RepositoryPath $repoPath ` -Arguments @('worktree', 'add', $resolvedWorktreePath, $BranchName) ` -FailureContext "branch '$BranchName' at '$resolvedWorktreePath'" $changeLocation = -not $NoSetLocation -and (-not $PSBoundParameters.ContainsKey('SetLocation') -or $SetLocation) if ($created -and $changeLocation) { Set-Location -LiteralPath (Resolve-CreatedWorktreePath -Path $resolvedWorktreePath -BasePath $repoPath) } } } } function Get-GitBranchUser { [CmdletBinding()] param( [Parameter(ValueFromPipelineByPropertyName)] [Alias('RepositoryPath', 'RepoPath')] [string]$Path ) $repoPath = Resolve-GitRepositoryPath -Path $Path if (-not $repoPath) { return } $candidate = $env:GITHUB_USER if (-not $candidate) { $email = git -C $repoPath config --get user.email 2>$null if ($email -match '^([^@]+)@') { $candidate = $Matches[1] } } if (-not $candidate) { $candidate = $env:USERNAME ?? $env:USER } $candidate = ($candidate -replace '[^A-Za-z0-9._-]', '-').Trim('-') if (-not $candidate) { throw 'Could not determine a branch user name. Set GITHUB_USER or configure git user.email.' } $candidate } function New-Worktree { <# .SYNOPSIS Create a new branch, check it out to a worktree and navigate to it. .DESCRIPTION Creates a new branch with a conventional prefix and checks it out to a worktree. The default kind is 'user', which produces user/<user>/<name>. The user segment comes from -UserName, GITHUB_USER, git user.email, or the OS user. Changes location after successful creation, like New-Repository. Use -NoSetLocation to keep the caller's current directory. Failed creation and -WhatIf never change location. .PARAMETER WorkName Name of the branch, without the kind prefix. .PARAMETER Kind The branch kind prefix. Defaults to 'user'. .PARAMETER UserName User segment for user branches. Defaults to the current Git or OS identity. .PARAMETER NoPrefix Use WorkName as the branch name verbatim, without the kind prefix (e.g. creating a branch named 'plain-work'). .PARAMETER Path Directory inside the git working tree to create the worktree from. Defaults to the current location. .PARAMETER WorktreePath Optional destination path for the new worktree. When omitted, the path is derived from the repository container and branch name. .PARAMETER SetLocation Temporary compatibility switch. -SetLocation still navigates to the new worktree; -SetLocation:$false preserves the current directory. Prefer omitting this switch to navigate, or use -NoSetLocation to stay. Cannot be combined with -NoSetLocation, even when either switch is explicitly false. .PARAMETER NoSetLocation Do not change to the created worktree. Cannot be combined with -SetLocation. .EXAMPLE New-Worktree -WorkName my-feature Creates branch user/<user>/my-feature in a worktree and navigates to it. .EXAMPLE New-Worktree -WorkName search-improvements -Kind feature -NoSetLocation Creates branch feature/search-improvements without changing the current directory. .EXAMPLE New-Worktree -WorkName 2025.04 -Kind release Creates branch release/2025.04 in a worktree and navigates to it. .EXAMPLE New-Worktree -WorkName plain-work -NoPrefix Creates a worktree for a new branch named exactly 'plain-work' and navigates to it. .EXAMPLE New-Worktree -Path C:\repos\project -WorkName my-feature -WorktreePath ../custom-feature Creates branch user/<user>/my-feature from the specified repository in the supplied worktree path and navigates there. Relative destinations are resolved from -Path. #> [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Default')] param( [Parameter(Mandatory, Position = 0)] [ValidateNotNullOrEmpty()] [string]$WorkName, [Parameter(Position = 1)] [ValidateSet('user', 'feature', 'release')] [string]$Kind = 'user', [string]$UserName, [switch]$NoPrefix, [Parameter(ValueFromPipelineByPropertyName)] [Alias('RepositoryPath', 'RepoPath')] [string]$Path, [ValidateNotNullOrEmpty()] [string]$WorktreePath, [Parameter(ParameterSetName = 'SetLocation')] [switch]$SetLocation, [Parameter(ParameterSetName = 'Default')] [switch]$NoSetLocation ) process { $repoPath = Resolve-GitRepositoryPath -Path $Path if (-not $repoPath) { return } $branchName = if ($NoPrefix) { $WorkName } else { switch ($Kind) { 'user' { $branchUser = if ($UserName) { $UserName } else { Get-GitBranchUser -Path $repoPath } "user/$branchUser/$WorkName" } 'feature' { "feature/$WorkName" } 'release' { "release/$WorkName" } } } $resolvedWorktreePath = if ($PSBoundParameters.ContainsKey('WorktreePath')) { $WorktreePath } else { Get-WorktreePath -BranchName $branchName -Path $repoPath } if (-not $resolvedWorktreePath) { return } if ($PSCmdlet.ShouldProcess($resolvedWorktreePath, "Create worktree for new branch '$branchName'")) { $created = Invoke-GitWorktreeAdd ` -RepositoryPath $repoPath ` -Arguments @('worktree', 'add', '-b', $branchName, $resolvedWorktreePath) ` -FailureContext "new branch '$branchName' at '$resolvedWorktreePath'" $changeLocation = -not $NoSetLocation -and (-not $PSBoundParameters.ContainsKey('SetLocation') -or $SetLocation) if ($created -and $changeLocation) { Set-Location -LiteralPath (Resolve-CreatedWorktreePath -Path $resolvedWorktreePath -BasePath $repoPath) } } } } function ConvertTo-ComparableWorktreePath { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path ) $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue | Select-Object -First 1 $fullPath = if ($resolved) { $resolved.ProviderPath } else { [IO.Path]::GetFullPath($Path) } $root = [IO.Path]::GetPathRoot($fullPath) if ($fullPath.Length -le $root.Length) { return $fullPath } $fullPath.TrimEnd([char[]]@([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)) } function Test-WorktreePathEquals { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Left, [Parameter(Mandatory)] [string]$Right ) $comparison = if ([IO.Path]::DirectorySeparatorChar -eq '\') { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } (ConvertTo-ComparableWorktreePath -Path $Left).Equals((ConvertTo-ComparableWorktreePath -Path $Right), $comparison) } function Resolve-WorktreeTarget { [CmdletBinding(DefaultParameterSetName = 'Path')] param( [Parameter(Mandatory, ParameterSetName = 'BranchName')] [string]$BranchName, [Parameter(Mandatory, ParameterSetName = 'Path')] [string]$Path ) $worktrees = @(Get-Worktrees) if ($PSCmdlet.ParameterSetName -eq 'Path') { $matches = @($worktrees | Where-Object { Test-WorktreePathEquals -Left $_.Path -Right $Path }) if ($matches.Count -eq 0) { Write-Error "No worktree was found at path '$Path'." return } if ($matches.Count -gt 1) { Write-Error "More than one worktree matched path '$Path'." return } return $matches[0] } $matches = @($worktrees | Where-Object Branch -eq $BranchName) if ($matches.Count -eq 0) { Write-Error "No worktree was found for branch '$BranchName'." return } if ($BranchName -eq '(detached)' -or ($matches | Where-Object Detached)) { Write-Error "Detached worktrees cannot be addressed by branch name because '(detached)' is ambiguous. Use -Path instead." return } if ($matches.Count -gt 1) { Write-Error "More than one worktree matched branch '$BranchName'. Use -Path instead." return } $matches[0] } function Remove-Worktree { <# .SYNOPSIS Remove a worktree and, by default, its backing local branch. .DESCRIPTION Resolves a worktree by branch name or path and deletes its backing local branch only after worktree removal succeeds. Detached worktrees have no branch to delete. Worktree removal and branch deletion are confirmed separately; declining worktree removal also prevents branch deletion. Branch cleanup retains the existing git branch -D behavior, including deleting unmerged branches. Use -KeepBranch to preserve the branch and its commits. No remote branch is deleted. -WhatIf previews both operations without changing either resource. Use -Confirm:$false for unattended removal. .PARAMETER BranchName Name of the branch. The branch is resolved through `Get-Worktrees`, so non-standard worktree locations are supported. Detached worktrees must be addressed by `-Path` because their branch label is ambiguous. .PARAMETER Path The actual filesystem path of the worktree to remove. Accepts pipeline input by property name from `Get-Worktrees` and related objects. .PARAMETER KeepBranch Remove only the worktree, preserving its backing local branch. Cannot be combined with an enabled -RemoveBranch switch. .PARAMETER RemoveBranch Compatibility switch: branch cleanup is now the default. Existing -RemoveBranch calls still work; explicitly passing -RemoveBranch:$false preserves the branch, as before. Prefer -KeepBranch in new scripts. .PARAMETER Force Pass a single --force to git worktree remove, allowing removal of a dirty worktree. Does not bypass confirmation, retry with additional force, or change the existing git branch -D cleanup behavior. .EXAMPLE Remove-Worktree -BranchName feature/old Confirms removal of the worktree and then deletion of its local branch. .EXAMPLE Remove-Worktree -BranchName feature/paused -KeepBranch Removes the worktree but keeps the local branch for later use. .EXAMPLE Remove-Worktree -BranchName feature/old -WhatIf Previews worktree removal and local branch deletion without changing either. .EXAMPLE Get-Worktrees | Where-Object Detached | Remove-Worktree -Force Removes detached worktrees by their real paths from pipeline input. #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'Path')] param( [Parameter(Mandatory, Position = 0, ParameterSetName = 'BranchName', ValueFromPipelineByPropertyName)] [ValidateSet([WorktreeSetValuesGenerator])] [Alias('Branch')] [string]$BranchName, [Parameter(Mandatory, ParameterSetName = 'Path', ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [string]$Path, [switch]$RemoveBranch, [switch]$Force = $false, [switch]$KeepBranch ) begin { if ($KeepBranch -and $RemoveBranch) { throw 'KeepBranch and RemoveBranch cannot both be enabled. Choose whether to keep or delete the local branch.' } $deleteBranch = -not $KeepBranch -and (-not $PSBoundParameters.ContainsKey('RemoveBranch') -or $RemoveBranch) } process { $target = if ($PSCmdlet.ParameterSetName -eq 'Path') { Resolve-WorktreeTarget -Path $Path } else { Resolve-WorktreeTarget -BranchName $BranchName } if (-not $target) { return } $worktreePath = $target.Path $hasBranch = -not $target.Detached -and $target.Branch -and $target.Branch -ne '(detached)' $worktreeRemoved = $false if ($PSCmdlet.ShouldProcess($worktreePath, 'Remove worktree')) { $removeArgs = @('worktree', 'remove') if ($Force) { $removeArgs += '--force' } $removeArgs += '--' $removeArgs += $worktreePath git @removeArgs $worktreeRemoved = $LASTEXITCODE -eq 0 if ($RemoveBranch -and -not $hasBranch) { Write-Warning 'The target worktree is detached; no branch was removed.' } elseif ($deleteBranch -and $hasBranch -and -not $worktreeRemoved) { Write-Warning "Worktree removal failed; leaving branch '$($target.Branch)' in place." } } # Preview both stages, but never delete a branch after failed or declined removal. if ($deleteBranch -and $hasBranch -and ($worktreeRemoved -or $WhatIfPreference)) { if ($PSCmdlet.ShouldProcess($target.Branch, 'Delete local branch (allow unmerged)')) { git branch -D -- $target.Branch } } } } function Move-Worktree { <# .SYNOPSIS Move a worktree to a new filesystem location. .DESCRIPTION Resolves a worktree by branch name or by its real filesystem path, refuses to move the repository's main/root worktree, and then runs `git worktree move` for the target. Git failures such as an existing destination or a locked worktree are reported with git's output. .PARAMETER BranchName Name of the branch whose worktree should be moved. The branch is resolved through `Get-Worktrees`, so non-standard worktree locations are supported. Detached worktrees must be addressed by `-Path` because their branch label is ambiguous. .PARAMETER Path The actual filesystem path of the worktree to move. Accepts pipeline input by property name from `Get-Worktrees` and related objects. .PARAMETER DestinationPath The new filesystem location for the worktree. .PARAMETER Force Pass `--force` to `git worktree move` for the cases git allows. .PARAMETER SetLocation Change the current location to the moved worktree path after a successful move. .EXAMPLE Move-Worktree -BranchName feature/my-work -DestinationPath ../moved-work Moves the worktree for feature/my-work to ../moved-work. .EXAMPLE Get-Worktrees | Where-Object Branch -eq feature/my-work | Move-Worktree -DestinationPath ../moved-work -SetLocation Moves a piped worktree by its Path property and then changes to the new path. #> [OutputType('WorktreeMoveResult')] [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Path')] param( [Parameter(Mandatory, Position = 0, ParameterSetName = 'BranchName', ValueFromPipelineByPropertyName)] [ValidateSet([WorktreeSetValuesGenerator])] [Alias('Branch')] [string]$BranchName, [Parameter(Mandatory, ParameterSetName = 'Path', ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter(Mandatory, Position = 1)] [ValidateNotNullOrEmpty()] [Alias('Destination', 'NewPath')] [string]$DestinationPath, [switch]$Force, [switch]$SetLocation ) process { $repoPath = Resolve-GitRepositoryPath if (-not $repoPath) { return } $target = if ($PSCmdlet.ParameterSetName -eq 'Path') { Resolve-WorktreeTarget -Path $Path } else { Resolve-WorktreeTarget -BranchName $BranchName } if (-not $target) { return } $root = Get-RootWorktree -Path $repoPath | Select-Object -First 1 if (-not $root) { Write-Error 'Could not identify the main/root worktree for this repository.' return } $oldPath = $target.Path if (Test-WorktreePathEquals -Left $oldPath -Right $root.Path) { Write-Error "The main/root worktree at '$oldPath' cannot be moved. Move a linked worktree instead, or clone the repository to a new location." return } # Resolve the destination against PowerShell's current location (not the # process directory) so relative and drive-relative paths behave as the # caller sees them; the path need not exist yet. $newPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($DestinationPath) if (Test-Path -LiteralPath $newPath) { Write-Error "Destination path '$newPath' already exists. Choose a path that does not exist before moving the worktree." return } $branch = if ($target.Branch) { $target.Branch } else { $BranchName } if (-not $branch) { $branch = '(unknown)' } if ($PSCmdlet.ShouldProcess($oldPath, "Move worktree for branch '$branch' to '$newPath'")) { $moveArgs = @('-C', $repoPath, 'worktree', 'move') if ($Force) { $moveArgs += '--force' } $moveArgs += @($oldPath, $newPath) $gitResult = Invoke-GitWorktreeMaintenance -Arguments $moveArgs if ($gitResult.ExitCode -ne 0) { $message = if ($gitResult.Messages) { $gitResult.Messages -join [Environment]::NewLine } else { 'No output.' } Write-Error "git worktree move failed for branch '$branch' from '$oldPath' to '$newPath' (exit $($gitResult.ExitCode)): $message" return } if ($SetLocation) { Set-Location -LiteralPath $newPath } [PSCustomObject]@{ PSTypeName = 'WorktreeMoveResult' Command = 'move' Branch = $branch OldPath = $oldPath NewPath = $newPath Force = $Force.IsPresent SetLocation = $SetLocation.IsPresent ExitCode = $gitResult.ExitCode Messages = $gitResult.Messages } } } } function Set-Worktree { <# .SYNOPSIS Change the current directory to the directory for a worktree. .PARAMETER BranchName The name of the branch to change to. The branch is resolved through `Get-Worktrees`, so non-standard worktree locations are supported. Detached worktrees must be addressed by `-Path` because their branch label is ambiguous. .PARAMETER Path The actual filesystem path of the worktree to change to. Accepts pipeline input by property name from `Get-Worktrees` and related objects. .EXAMPLE Set-Worktree -BranchName main Changes the current directory to the main branch worktree. .EXAMPLE Get-Worktrees | Where-Object Detached | Select-Object -First 1 | Set-Worktree Changes to a detached worktree by using its real path from pipeline input. #> [CmdletBinding(DefaultParameterSetName = 'Path')] param( [Parameter(Mandatory, Position = 0, ParameterSetName = 'BranchName', ValueFromPipelineByPropertyName)] [ValidateSet([WorktreeSetValuesGenerator])] [Alias('Branch')] [string]$BranchName, [Parameter(Mandatory, ParameterSetName = 'Path', ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [string]$Path ) process { $target = if ($PSCmdlet.ParameterSetName -eq 'Path') { Resolve-WorktreeTarget -Path $Path } else { Resolve-WorktreeTarget -BranchName $BranchName } if (-not $target) { return } if (Test-Path -LiteralPath $target.Path -PathType Container) { Set-Location -LiteralPath $target.Path } else { Write-Error "Worktree path '$($target.Path)' does not exist." } } } |