Public/Worktrees.ps1

using module ../Classes/WorktreeSetValuesGenerator.psm1

function Resolve-GitRepositoryPath {
    <#
    .SYNOPSIS
    Resolve and validate a path inside a git working tree.
    #>

    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('RepositoryPath', 'RepoPath')]
        [string]$Path,

        [switch]$AllowBare
    )

    $candidate = if ($Path) { $Path } else { (Get-Location).ProviderPath }
    try {
        $resolved = Resolve-Path -LiteralPath $candidate -ErrorAction Stop | Select-Object -First 1
        $providerPath = $resolved.ProviderPath
    } catch {
        Write-Error "Git repository path not found: '$candidate'."
        return
    }

    $inside = git -C $providerPath rev-parse --is-inside-work-tree 2>$null
    if ($LASTEXITCODE -ne 0 -or "$inside".Trim() -ne 'true') {
        if ($AllowBare) {
            $bare = git -C $providerPath rev-parse --is-bare-repository 2>$null
            if ($LASTEXITCODE -eq 0 -and "$bare".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
    )

    $gitArguments = if ($RepositoryPath) { @('-C', $RepositoryPath) + $Arguments } else { $Arguments }
    $output = & git @gitArguments 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,

        [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
    Checkout an existing branch to a worktree
    .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
    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 -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.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$BranchName,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('RepositoryPath', 'RepoPath')]
        [string]$Path,

        [ValidateNotNullOrEmpty()]
        [string]$WorktreePath,

        [switch]$SetLocation = $false
    )
    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'"
            if ($created -and $SetLocation) {
                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, checked out to a worktree.
    .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.
    .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. checking out an existing branch like 'main' or 'master').
    .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
    Whether to change the current directory to the new worktree.
    .EXAMPLE
    New-Worktree -WorkName my-feature -SetLocation
    Creates branch user/<user>/my-feature in a worktree and navigates to it.
    .EXAMPLE
    New-Worktree -WorkName search-improvements -Kind feature
    Creates branch feature/search-improvements in a worktree.
    .EXAMPLE
    New-Worktree -WorkName 2025.04 -Kind release -SetLocation
    Creates branch release/2025.04 in a worktree and navigates to it.
    .EXAMPLE
    New-Worktree -WorkName main -NoPrefix -SetLocation
    Creates a worktree for a branch named exactly 'main' with no kind prefix.
    .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.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    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,

        [switch]$SetLocation = $false
    )
    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'"
            if ($created -and $SetLocation) {
                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 by branch name or path.
    .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 RemoveBranch
    Also remove the branch when the target worktree is backed by a branch.
    .PARAMETER Force
    Force the removal of the worktree.
    .EXAMPLE
    Remove-Worktree -BranchName feature/old -RemoveBranch
    Removes the worktree for the branch and deletes the branch.
    .EXAMPLE
    Get-Worktrees | Where-Object Detached | Remove-Worktree -Force
    Removes detached worktrees by their real paths from pipeline input.
    #>

    [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,

        [switch]$RemoveBranch,
        [switch]$Force = $false
    )
    process {
        $target = if ($PSCmdlet.ParameterSetName -eq 'Path') {
            Resolve-WorktreeTarget -Path $Path
        } else {
            Resolve-WorktreeTarget -BranchName $BranchName
        }
        if (-not $target) { return }

        $worktreePath = $target.Path
        if ($PSCmdlet.ShouldProcess($worktreePath, 'Remove worktree')) {
            $removeArgs = @('worktree', 'remove')
            if ($Force) { $removeArgs += '--force' }
            $removeArgs += '--'
            $removeArgs += $worktreePath
            git @removeArgs
            $worktreeRemoved = $LASTEXITCODE -eq 0
            if ($RemoveBranch) {
                if ($target.Detached -or $target.Branch -eq '(detached)' -or -not $target.Branch) {
                    Write-Warning 'The target worktree is detached; no branch was removed.'
                } elseif ($worktreeRemoved) {
                    git branch -D -- $target.Branch
                } else {
                    Write-Warning "Worktree removal failed; leaving branch '$($target.Branch)' in place."
                }
            }
        }
    }
}

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