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."
        }
    }
}