Files
PowerToys/tools/build/WorktreeLib.ps1

152 lines
6.2 KiB
PowerShell
Raw Permalink Normal View History

Introduce worktree helper scripts for faster multi-branch development in PowerToys (#42076) <!-- Enter a brief description/summary of your PR here. What does it fix/what does it change/how was it tested (even manually, if necessary)? --> ## Summary of the Pull Request This pull request introduces a new suite of helper scripts for managing Git worktrees in the `tools/build` directory, along with comprehensive documentation. The scripts streamline common workflows such as creating, reusing, and deleting worktrees for feature branches, forks, and issue-based development, making it easier for developers to work on multiple changes in parallel without duplicating the repository. Each script is provided as both a PowerShell (`.ps1`) and Windows batch (`.cmd`) wrapper for convenience. A detailed markdown guide explains usage patterns, scenarios, and best practices. **New worktree management scripts:** * Added `New-WorktreeFromFork.ps1`/`.cmd` to create a worktree from a branch in a personal fork, handling remote creation and branch tracking automatically. [[1]](diffhunk://#diff-ea4d43777029cdde7fb9fda8ee6a0ed3dcfd75b22ed6ae566c6a77797c8bef54R1-R111) [[2]](diffhunk://#diff-1314b08f84ac8c2e7d020e5584d9f2f19dbf116bbc13c14de0de432006912cfeR1-R4) * Added `New-WorktreeFromBranch.ps1`/`.cmd` to create or reuse a worktree for an existing local or remote branch, with logic to fetch and track branches as needed. [[1]](diffhunk://#diff-07c08acfb570e1b54647370cae17e663e76ee8cb09614cac7a23a9367f625a3eR1-R69) [[2]](diffhunk://#diff-6297be534792c3e6d1bc377b84bcd20b2eb5b3de84d4376a2592b25fc9a88a88R1-R4) * Added `New-WorktreeFromIssue.ps1`/`.cmd` to create a new issue branch from a base ref (default `origin/main`), slugifying the issue title for branch naming. [[1]](diffhunk://#diff-36cb35f3b814759c60f770fc9cc1cc9fa10ceee53811d95a85881d8e69c1ab07R1-R67) [[2]](diffhunk://#diff-890880241ffc24b5d29ddb69ce4c19697a2fce6be6861d0a24d02ebf65b35694R1-R4) * Added `Delete-Worktree.ps1`/`.cmd` to safely remove a worktree, with options to force removal, keep the local branch, or retain orphan fork remotes. Includes robust error handling and manual recovery guidance. [[1]](diffhunk://#diff-8a335544864c1630d7f9bec6f4113c10d84b8e26054996735da41516ad93e173R1-R120) [[2]](diffhunk://#diff-19a810e57f8b82e1dc2476f35d051eb43f2d31e4f68ca7c011c89fd297718020R1-R4) **Documentation:** * Introduced `Wokrtree-Guidelines.md`, a comprehensive guide covering the purpose, usage, flows, naming conventions, troubleshooting, and best practices for the new worktree scripts.
2025-10-09 21:11:28 -07:00
# WorktreeLib.ps1 - shared helpers
function Info { param([string]$Message) Write-Host $Message -ForegroundColor Cyan }
function Warn { param([string]$Message) Write-Host $Message -ForegroundColor Yellow }
function Err { param([string]$Message) Write-Host $Message -ForegroundColor Red }
function Get-RepoRoot {
$root = git rev-parse --show-toplevel 2>$null
if (-not $root) { throw 'Not inside a git repository.' }
return $root
}
function Get-WorktreeBasePath {
param([string]$RepoRoot)
# Always use parent of repo root (folder that contains the main repo directory)
$parent = Split-Path -Parent $RepoRoot
if (-not (Test-Path $parent)) { throw "Parent path for repo root not found: $parent" }
return (Resolve-Path $parent).ProviderPath
}
function Get-ShortHashFromString {
param([Parameter(Mandatory)][string]$Text)
$md5 = [System.Security.Cryptography.MD5]::Create()
try {
$bytes = [Text.Encoding]::UTF8.GetBytes($Text)
$digest = $md5.ComputeHash($bytes)
return -join ($digest[0..1] | ForEach-Object { $_.ToString('x2') })
} finally { $md5.Dispose() }
}
function Initialize-SubmodulesIfAny {
param([string]$RepoRoot,[string]$WorktreePath)
$hasGitmodules = Test-Path (Join-Path $RepoRoot '.gitmodules')
if ($hasGitmodules) {
git -C $WorktreePath submodule sync --recursive | Out-Null
git -C $WorktreePath submodule update --init --recursive | Out-Null
return $true
}
return $false
}
function New-WorktreeForExistingBranch {
param(
[Parameter(Mandatory)][string] $Branch,
[Parameter(Mandatory)][string] $VSCodeProfile
)
$repoRoot = Get-RepoRoot
git show-ref --verify --quiet "refs/heads/$Branch"; if ($LASTEXITCODE -ne 0) { throw "Branch '$Branch' does not exist locally." }
# Detect existing worktree for this branch
$entries = Get-WorktreeEntries
$match = $entries | Where-Object { $_.Branch -eq $Branch } | Select-Object -First 1
if ($match) {
Info "Reusing existing worktree for '$Branch': $($match.Path)"
code --new-window "$($match.Path)" --profile "$VSCodeProfile" | Out-Null
return
}
$safeBranch = ($Branch -replace '[\\/:*?"<>|]','-')
$hash = Get-ShortHashFromString -Text $safeBranch
$folderName = "$(Split-Path -Leaf $repoRoot)-$hash"
$base = Get-WorktreeBasePath -RepoRoot $repoRoot
$folder = Join-Path $base $folderName
git worktree add $folder $Branch
$inited = Initialize-SubmodulesIfAny -RepoRoot $repoRoot -WorktreePath $folder
code --new-window "$folder" --profile "$VSCodeProfile" | Out-Null
Info "Created worktree for branch '$Branch' at $folder."; if ($inited) { Info 'Submodules initialized.' }
}
function Get-WorktreeEntries {
# Returns objects with Path and Branch (branch without refs/heads/ prefix)
$lines = git worktree list --porcelain 2>$null
if (-not $lines) { return @() }
$entries = @(); $current=@{}
foreach($l in $lines){
if ($l -eq '') { if ($current.path -and $current.branch){ $entries += ,([pscustomobject]@{ Path=$current.path; Branch=($current.branch -replace '^refs/heads/','') }) }; $current=@{}; continue }
if ($l -like 'worktree *'){ $current.path = ($l -split ' ',2)[1] }
elseif ($l -like 'branch *'){ $current.branch = ($l -split ' ',2)[1].Trim() }
}
if ($current.path -and $current.branch){ $entries += ,([pscustomobject]@{ Path=$current.path; Branch=($current.branch -replace '^refs/heads/','') }) }
return ($entries | Sort-Object Path,Branch -Unique)
}
function Get-BranchUpstreamRemote {
param([Parameter(Mandatory)][string]$Branch)
# Returns remote name if branch has an upstream, else $null
$ref = git rev-parse --abbrev-ref --symbolic-full-name "$Branch@{upstream}" 2>$null
if ($LASTEXITCODE -ne 0 -or -not $ref) { return $null }
if ($ref -match '^(?<remote>[^/]+)/.+$') { return $Matches.remote }
return $null
}
function Show-IssueFarmCommonFooter {
Info '--- Common Manual Steps ---'
Info 'List worktree: git worktree list --porcelain'
Info 'List branches: git branch -vv'
Info 'List remotes: git remote -v'
Info 'Prune worktree: git worktree prune'
Info 'Remove worktree dir: Remove-Item -Recurse -Force <path>'
Info 'Reset branch: git reset --hard HEAD'
}
function Show-WorktreeExecutionSummary {
param(
[string]$CurrentBranch,
[string]$WorktreePath
)
Info '--- Summary ---'
if ($CurrentBranch) { Info "Branch: $CurrentBranch" }
if ($WorktreePath) { Info "Worktree path: $WorktreePath" }
$entries = Get-WorktreeEntries
if ($entries.Count -gt 0) {
Info 'Existing worktrees:'
$entries | ForEach-Object { Info (" {0} -> {1}" -f $_.Branch,$_.Path) }
}
Info 'Remotes:'
git remote -v 2>$null | Sort-Object | Get-Unique | ForEach-Object { Info " $_" }
}
function Show-FileEmbeddedHelp {
param([string]$ScriptPath)
if (-not (Test-Path $ScriptPath)) { throw "Cannot load help; file missing: $ScriptPath" }
$content = Get-Content -LiteralPath $ScriptPath -ErrorAction Stop
$inBlock=$false
foreach($line in $content){
if ($line -match '^<#!') { $inBlock=$true; continue }
if ($line -match '#>$') { break }
if ($inBlock) { Write-Host $line }
}
Show-IssueFarmCommonFooter
}
function Set-BranchUpstream {
param(
[Parameter(Mandatory)][string]$LocalBranch,
[Parameter(Mandatory)][string]$RemoteName,
[Parameter(Mandatory)][string]$RemoteBranchPath
)
$current = git rev-parse --abbrev-ref --symbolic-full-name "$LocalBranch@{upstream}" 2>$null
if (-not $current) {
Info "Setting upstream: $LocalBranch -> $RemoteName/$RemoteBranchPath"
git branch --set-upstream-to "$RemoteName/$RemoteBranchPath" $LocalBranch 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { Warn "Failed to set upstream automatically. Run: git branch --set-upstream-to $RemoteName/$RemoteBranchPath $LocalBranch" }
return
}
if ($current -ne "$RemoteName/$RemoteBranchPath") {
Warn "Upstream mismatch ($current != $RemoteName/$RemoteBranchPath); updating..."
git branch --set-upstream-to "$RemoteName/$RemoteBranchPath" $LocalBranch 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { Warn "Could not update upstream; manual fix: git branch --set-upstream-to $RemoteName/$RemoteBranchPath $LocalBranch" } else { Info 'Upstream corrected.' }
} else { Info "Upstream already: $current" }
}