Public/Worktrees.ps1
|
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. .EXAMPLE Get-Worktrees Returns all worktrees for the current repository. #> [OutputType('Worktree')] [CmdletBinding()] param() $lines = git worktree list --porcelain $entry = @{} foreach ($line in $lines) { if ([string]::IsNullOrWhiteSpace($line)) { if ($entry.Count -gt 0) { $entry['PSTypeName'] = 'Worktree' [PSCustomObject]$entry $entry = @{} } 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)' } } # Emit the last entry if ($entry.Count -gt 0) { $entry['PSTypeName'] = 'Worktree' [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. .EXAMPLE Get-CurrentWorktree Returns the worktree object for the current location. #> [CmdletBinding()] param() $currentPath = (Get-Location).Path Get-Worktrees | 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. .EXAMPLE Get-RepositoryName Returns the repository name, e.g. 'MyRepo'. #> [CmdletBinding()] param() git 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. .EXAMPLE Get-RootWorktree Returns the worktree object for the root of the repository. #> [CmdletBinding()] param() $commonDir = git rev-parse --path-format=absolute --git-common-dir 2>$null if ($LASTEXITCODE -ne 0 -or -not $commonDir) { Write-Error 'Not in a git repository.' return } $rootPath = [IO.Path]::GetFullPath((Split-Path $commonDir -Parent)) Get-Worktrees | Where-Object { [IO.Path]::GetFullPath("$($_.Path)").Equals($rootPath, [System.StringComparison]::OrdinalIgnoreCase) } } # Validate-set generator that supplies the current repo's worktree branch names # for tab-completion / -ValidateSet on worktree parameters. Class WorktreeSetValuesGenerator : System.Management.Automation.IValidateSetValuesGenerator { [string[]] GetValidValues() { <# .SYNOPSIS Return the branch name of every worktree in the current repository, used to supply -ValidateSet/tab-completion values for worktree parameters. #> return [string[]](Get-Worktrees | Select-Object -ExpandProperty Branch) } } 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. .EXAMPLE Get-WorktreePath -BranchName feature/my-feature Returns the expected worktree path for the given branch. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$BranchName ) $root = Get-RootWorktree | 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 Add-Worktree { <# .SYNOPSIS Checkout an existing branch to a worktree .PARAMETER BranchName Name of the branch .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. #> [CmdletBinding(SupportsShouldProcess)] param( [string]$BranchName, [switch]$SetLocation = $false ) process { $worktreePath = Get-WorktreePath -BranchName $BranchName if ($PSCmdlet.ShouldProcess($worktreePath, "Add worktree for branch '$BranchName'")) { git worktree add $worktreePath $BranchName if (($LASTEXITCODE -eq 0) -and $SetLocation) { Set-Location -Path $worktreePath } } } } function Get-GitBranchUser { [CmdletBinding()] param() $candidate = $env:GITHUB_USER if (-not $candidate) { $email = git 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 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. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, Position = 0)] [ValidateNotNullOrEmpty()] [string]$WorkName, [Parameter(Position = 1)] [ValidateSet('user', 'feature', 'release')] [string]$Kind = 'user', [string]$UserName, [switch]$NoPrefix, [switch]$SetLocation = $false ) process { $branchName = if ($NoPrefix) { $WorkName } else { switch ($Kind) { 'user' { $branchUser = if ($UserName) { $UserName } else { Get-GitBranchUser } "user/$branchUser/$WorkName" } 'feature' { "feature/$WorkName" } 'release' { "release/$WorkName" } } } $worktreePath = Get-WorktreePath -BranchName $branchName if ($PSCmdlet.ShouldProcess($worktreePath, "Create worktree for new branch '$branchName'")) { git worktree add -b $branchName $worktreePath if (($LASTEXITCODE -eq 0) -and $SetLocation) { Set-Location -Path $worktreePath } } } } function Remove-Worktree { <# .SYNOPSIS Remove a worktree by branch name .PARAMETER BranchName Name of the branch .PARAMETER RemoveBranch Also remove the branch .PARAMETER Force Force the removal of the worktree .EXAMPLE Remove-Worktree -BranchName feature/old -RemoveBranch Removes the worktree and deletes the branch. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(ValueFromPipelineByPropertyName)] [ValidateSet([WorktreeSetValuesGenerator])] [Alias('Branch')] [string]$BranchName, [switch]$RemoveBranch, [switch]$Force = $false ) process { $worktreePath = Get-WorktreePath -BranchName $BranchName if ($PSCmdlet.ShouldProcess($worktreePath, 'Remove worktree')) { if ($Force) { git worktree remove $worktreePath --force } else { git worktree remove $worktreePath } $worktreeRemoved = $LASTEXITCODE -eq 0 if ($RemoveBranch) { if ($worktreeRemoved) { git branch -D $BranchName } else { Write-Warning "Worktree removal failed; leaving branch '$BranchName' in place." } } } } } function Set-Worktree { <# .SYNOPSIS Change the current directory to the directory for a worktree. .PARAMETER BranchName The name of the branch to change to. .EXAMPLE Set-Worktree -BranchName main Changes the current directory to the main branch worktree. #> [CmdletBinding()] param( [Parameter(ValueFromPipelineByPropertyName)] [ValidateSet([WorktreeSetValuesGenerator])] [Alias('Branch')] [string]$BranchName ) process { $worktreePath = Get-Worktrees | Where-Object Branch -eq $BranchName | Select-Object -ExpandProperty Path if ($worktreePath -and (Test-Path -Path $worktreePath)) { Set-Location -Path $worktreePath } } } |