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.
This commit is contained in:
Gordon Lam
2025-10-09 21:11:28 -07:00
committed by GitHub
parent 075bbb46cb
commit 1e3429dd3a
11 changed files with 677 additions and 0 deletions

View File

@@ -580,6 +580,7 @@ GETSCREENSAVERRUNNING
GETSECKEY
GETSTICKYKEYS
GETTEXTLENGTH
gitmodules
GHND
GMEM
GNumber
@@ -1327,6 +1328,7 @@ PRTL
prvpane
psapi
pscid
pscustomobject
PSECURITY
psfgao
psfi
@@ -1977,6 +1979,7 @@ WORKSPACESEDITOR
WORKSPACESLAUNCHER
WORKSPACESSNAPSHOTTOOL
WORKSPACESWINDOWARRANGER
Worktree
wox
wparam
wpf

View File

@@ -0,0 +1,4 @@
@echo off
setlocal
set SCRIPT_DIR=%~dp0
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%Delete-Worktree.ps1" %*

View File

@@ -0,0 +1,130 @@
<#!
.SYNOPSIS
Remove a git worktree (and optionally its local branch and orphan fork remote).
.DESCRIPTION
Locates a worktree by branch/path pattern (supports wildcards). Ensures the primary repository
root is never removed. Optionally discards local changes with -Force. Deletes associated branch
unless -KeepBranch. If the branch tracked a non-origin remote with no remaining tracking
branches, that remote is removed unless -KeepRemote.
.PARAMETER Pattern
Branch name or path fragment (wildcards * ? allowed). If multiple matches found they are listed
and no deletion occurs.
.PARAMETER Force
Discard uncommitted changes and attempt aggressive cleanup on failure.
.PARAMETER KeepBranch
Preserve the local branch (only remove the worktree directory entry).
.PARAMETER KeepRemote
Preserve any orphan fork remote even if no branches still track it.
.EXAMPLE
./Delete-Worktree.ps1 -Pattern feature/login
.EXAMPLE
./Delete-Worktree.ps1 -Pattern fork-user-featureX -Force
.EXAMPLE
./Delete-Worktree.ps1 -Pattern hotfix -KeepBranch
.NOTES
Manual recovery:
git worktree list --porcelain
git worktree prune
Remove-Item -LiteralPath <path> -Recurse -Force
git branch -D <branch>
git remote remove <remote>
git worktree prune
#>
param(
[string] $Pattern,
[switch] $Force,
[switch] $KeepBranch,
[switch] $KeepRemote,
[switch] $Help
)
. "$PSScriptRoot/WorktreeLib.ps1"
if ($Help -or -not $Pattern) { Show-FileEmbeddedHelp -ScriptPath $MyInvocation.MyCommand.Path; return }
try {
$repoRoot = Get-RepoRoot
$entries = Get-WorktreeEntries
if (-not $entries -or $entries.Count -eq 0) { throw 'No worktrees found.' }
$hasWildcard = $Pattern -match '[\*\?]'
$matchPattern = if ($hasWildcard) { $Pattern } else { "*${Pattern}*" }
$found = $entries | Where-Object { $_.Branch -and ( $_.Branch -like $matchPattern -or $_.Path -like $matchPattern ) }
if (-not $found -or $found.Count -eq 0) { throw "No worktree matches pattern '$Pattern'" }
if ($found.Count -gt 1) {
Warn 'Pattern matches multiple worktrees:'
$found | ForEach-Object { Info (" {0} {1}" -f $_.Branch, $_.Path) }
return
}
$target = $found | Select-Object -First 1
$branch = $target.Branch
$folder = $target.Path
if (-not $branch) { throw 'Resolved worktree has no branch (detached); refusing removal.' }
try { $folder = (Resolve-Path -LiteralPath $folder -ErrorAction Stop).ProviderPath } catch {}
$primary = (Resolve-Path -LiteralPath $repoRoot).ProviderPath
if ([IO.Path]::GetFullPath($folder).TrimEnd('\\/') -ieq [IO.Path]::GetFullPath($primary).TrimEnd('\\/')) { throw 'Refusing to remove the primary worktree (repository root).' }
$status = git -C $folder status --porcelain 2>$null
if ($LASTEXITCODE -ne 0) { throw "Unable to get git status for $folder" }
if (-not $Force -and $status) { throw 'Worktree has uncommitted changes. Use -Force to discard.' }
if ($Force -and $status) {
Warn '[Force] Discarding local changes'
git -C $folder reset --hard HEAD | Out-Null
git -C $folder clean -fdx | Out-Null
}
if ($Force) { git worktree remove --force $folder } else { git worktree remove $folder }
if ($LASTEXITCODE -ne 0) {
$exit1 = $LASTEXITCODE
$errMsg = "git worktree remove failed (exit $exit1)"
if ($Force) {
Warn 'Primary removal failed; performing aggressive fallback (Force implies brute).'
try { git -C $folder submodule deinit -f --all 2>$null | Out-Null } catch {}
try { git -C $folder clean -dfx 2>$null | Out-Null } catch {}
try { Get-ChildItem -LiteralPath $folder -Recurse -Force -ErrorAction SilentlyContinue | ForEach-Object { try { $_.IsReadOnly = $false } catch {} } } catch {}
if (Test-Path $folder) { try { Remove-Item -LiteralPath $folder -Recurse -Force -ErrorAction Stop } catch { Err "Manual directory removal failed: $($_.Exception.Message)" } }
git worktree prune 2>$null | Out-Null
if (Test-Path $folder) { throw "$errMsg and aggressive cleanup did not fully remove directory: $folder" } else { Info "Aggressive cleanup removed directory $folder." }
} else {
throw "$errMsg. Rerun with -Force to attempt aggressive cleanup."
}
}
# Determine upstream before potentially deleting branch
$upRemote = Get-BranchUpstreamRemote -Branch $branch
$looksForkName = $branch -like 'fork-*'
if (-not $KeepBranch) {
git branch -D $branch 2>$null | Out-Null
if (-not $KeepRemote -and $upRemote -and $upRemote -ne 'origin') {
$otherTracking = git for-each-ref --format='%(refname:short)|%(upstream:short)' refs/heads 2>$null |
Where-Object { $_ -and ($_ -notmatch "^$branch\|") } |
ForEach-Object { $parts = $_.Split('|',2); if ($parts[1] -match '^(?<r>[^/]+)/'){ $parts[0],$Matches.r } } |
Where-Object { $_[1] -eq $upRemote }
if (-not $otherTracking) {
Warn "Removing orphan remote '$upRemote' (no more tracking branches)"
git remote remove $upRemote 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { Warn "Failed to remove remote '$upRemote' (you may remove manually)." }
} else { Info "Remote '$upRemote' retained (other branches still track it)." }
} elseif ($looksForkName -and -not $KeepRemote -and -not $upRemote) {
Warn 'Branch looks like a fork branch (name pattern), but has no upstream remote; nothing to clean.'
}
}
Info "Removed worktree ($branch) at $folder."; if (-not $KeepBranch) { Info 'Branch deleted.' }
Show-WorktreeExecutionSummary -CurrentBranch $branch
} catch {
Err "Error: $($_.Exception.Message)"
Warn 'Manual cleanup guidelines:'
Info ' git worktree list --porcelain'
Info ' git worktree prune'
Info ' # If still present:'
Info ' Remove-Item -LiteralPath <path> -Recurse -Force'
Info ' git branch -D <branch> (if you also want to drop local branch)'
Info ' git remote remove <remote> (if orphan fork remote remains)'
Info ' git worktree prune'
exit 1
}

View File

@@ -0,0 +1,4 @@
@echo off
setlocal
set SCRIPT_DIR=%~dp0
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromBranch.ps1" %*

View File

@@ -0,0 +1,78 @@
<#!
.SYNOPSIS
Create (or reuse) a worktree for an existing local or remote (origin) branch.
.DESCRIPTION
Normalizes origin/<name> to <name>. If the branch does not exist locally (and -NoFetch is not
provided) it will fetch and create a tracking branch from origin. Reuses any existing worktree
bound to the branch; otherwise creates a new one adjacent to the repository root.
.PARAMETER Branch
Branch name (local or origin/<name> form) to materialize as a worktree.
.PARAMETER VSCodeProfile
VS Code profile to open (Default).
.PARAMETER NoFetch
Skip fetch if branch missing locally; script will error instead of creating it.
.EXAMPLE
./New-WorktreeFromBranch.ps1 -Branch feature/login
.EXAMPLE
./New-WorktreeFromBranch.ps1 -Branch origin/bugfix/nullref
.EXAMPLE
./New-WorktreeFromBranch.ps1 -Branch release/v1 -NoFetch
.NOTES
Manual recovery:
git fetch origin && git checkout <branch>
git worktree add ../RepoName-XX <branch>
code ../RepoName-XX --profile Default
#>
param(
[string] $Branch,
[Alias('Profile')][string] $VSCodeProfile = 'Default',
[switch] $NoFetch,
[switch] $Help
)
. "$PSScriptRoot/WorktreeLib.ps1"
if ($Help -or -not $Branch) { Show-FileEmbeddedHelp -ScriptPath $MyInvocation.MyCommand.Path; return }
# Normalize origin/<name> to <name>
if ($Branch -match '^(origin|upstream|main|master)/.+') {
if ($Branch -match '^(origin|upstream)/(.+)$') { $Branch = $Matches[2] }
}
try {
git show-ref --verify --quiet "refs/heads/$Branch"
if ($LASTEXITCODE -ne 0) {
if (-not $NoFetch) {
Warn "Local branch '$Branch' not found; attempting remote fetch..."
git fetch --all --prune 2>$null | Out-Null
$remoteRef = "origin/$Branch"
git show-ref --verify --quiet "refs/remotes/$remoteRef"
if ($LASTEXITCODE -eq 0) {
git branch --track $Branch $remoteRef 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Failed to create tracking branch '$Branch' from $remoteRef" }
Info "Created local tracking branch '$Branch' from $remoteRef."
} else { throw "Branch '$Branch' not found locally or on origin. Use git fetch or specify a valid branch." }
} else { throw "Branch '$Branch' does not exist locally (remote fetch disabled with -NoFetch)." }
}
New-WorktreeForExistingBranch -Branch $Branch -VSCodeProfile $VSCodeProfile
$after = Get-WorktreeEntries | Where-Object { $_.Branch -eq $Branch }
$path = ($after | Select-Object -First 1).Path
Show-WorktreeExecutionSummary -CurrentBranch $Branch -WorktreePath $path
} catch {
Err "Error: $($_.Exception.Message)"
Warn 'Manual steps:'
Info ' git fetch origin'
Info " git checkout $Branch (or: git branch --track $Branch origin/$Branch)"
Info ' git worktree add ../<Repo>-XX <branch>'
Info ' code ../<Repo>-XX'
exit 1
}

View File

@@ -0,0 +1,4 @@
@echo off
setlocal
set SCRIPT_DIR=%~dp0
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromFork.ps1" %*

View File

@@ -0,0 +1,127 @@
<#!
.SYNOPSIS
Create (or reuse) a worktree from a branch in a personal fork: <ForkUser>:<ForkBranch>.
.DESCRIPTION
Adds a transient uniquely named fork remote (fork-xxxxx) unless -RemoteName specified.
Fetches only the target branch (fallback full fetch once if needed), creates a local tracking
branch (fork-<user>-<sanitized-branch> or custom alias), and delegates worktree creation/reuse
to shared helpers in WorktreeLib.
.PARAMETER Spec
Fork spec in the form <ForkUser>:<ForkBranch>.
.PARAMETER ForkRepo
Repository name in the fork (default: PowerToys).
.PARAMETER RemoteName
Desired remote name; if left as 'fork' a unique suffix will be generated.
.PARAMETER BranchAlias
Optional local branch name override; defaults to fork-<user>-<sanitized-branch>.
.PARAMETER VSCodeProfile
VS Code profile to pass through to worktree opening (Default profile by default).
.EXAMPLE
./New-WorktreeFromFork.ps1 -Spec alice:feature/new-ui
.EXAMPLE
./New-WorktreeFromFork.ps1 -Spec bob:bugfix/crash -BranchAlias fork-bob-crash
.NOTES
Manual equivalent if this script fails:
git remote add fork-temp https://github.com/<user>/<repo>.git
git fetch fork-temp
git branch --track fork-<user>-<branch> fork-temp/<branch>
git worktree add ../Repo-XX fork-<user>-<branch>
code ../Repo-XX
#>
param(
[string] $Spec,
[string] $ForkRepo = 'PowerToys',
[string] $RemoteName = 'fork',
[string] $BranchAlias,
[Alias('Profile')][string] $VSCodeProfile = 'Default',
[switch] $Help
)
. "$PSScriptRoot/WorktreeLib.ps1"
if ($Help -or -not $Spec) { Show-FileEmbeddedHelp -ScriptPath $MyInvocation.MyCommand.Path; return }
$repoRoot = git rev-parse --show-toplevel 2>$null
if (-not $repoRoot) { throw 'Not inside a git repository.' }
# Parse spec
if ($Spec -notmatch '^[^:]+:.+$') { throw "Spec must be <ForkUser>:<ForkBranch>, got '$Spec'" }
$ForkUser,$ForkBranch = $Spec.Split(':',2)
$forkUrl = "https://github.com/$ForkUser/$ForkRepo.git"
# Auto-suffix remote name if user left default 'fork'
$allRemotes = @(git remote 2>$null)
if ($RemoteName -eq 'fork') {
$chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
do {
$suffix = -join ((1..5) | ForEach-Object { $chars[(Get-Random -Max $chars.Length)] })
$candidate = "fork-$suffix"
} while ($allRemotes -contains $candidate)
$RemoteName = $candidate
Info "Assigned unique remote name: $RemoteName"
}
$existing = $allRemotes | Where-Object { $_ -eq $RemoteName }
if (-not $existing) {
Info "Adding remote $RemoteName -> $forkUrl"
git remote add $RemoteName $forkUrl | Out-Null
} else {
$currentUrl = git remote get-url $RemoteName 2>$null
if ($currentUrl -ne $forkUrl) { Warn "Remote $RemoteName points to $currentUrl (expected $forkUrl). Using existing." }
}
## Note: Verbose fetch & stale lock auto-clean removed for simplicity.
try {
Info "Fetching branch '$ForkBranch' from $RemoteName..."
& git fetch $RemoteName $ForkBranch 1>$null 2>$null
$fetchExit = $LASTEXITCODE
if ($fetchExit -ne 0) {
# Retry full fetch silently once (covers servers not supporting branch-only fetch syntax)
& git fetch $RemoteName 1>$null 2>$null
$fetchExit = $LASTEXITCODE
}
if ($fetchExit -ne 0) { throw "Fetch failed for remote $RemoteName (branch $ForkBranch)." }
$remoteRef = "refs/remotes/$RemoteName/$ForkBranch"
git show-ref --verify --quiet $remoteRef
if ($LASTEXITCODE -ne 0) { throw "Remote branch not found: $RemoteName/$ForkBranch" }
$sanitizedBranch = ($ForkBranch -replace '[\\/:*?"<>|]','-')
if ($BranchAlias) { $localBranch = $BranchAlias } else { $localBranch = "fork-$ForkUser-$sanitizedBranch" }
git show-ref --verify --quiet "refs/heads/$localBranch"
if ($LASTEXITCODE -ne 0) {
Info "Creating local tracking branch $localBranch from $RemoteName/$ForkBranch"
git branch --track $localBranch "$RemoteName/$ForkBranch" 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Failed to create local tracking branch $localBranch" }
} else { Info "Local branch $localBranch already exists." }
New-WorktreeForExistingBranch -Branch $localBranch -VSCodeProfile $VSCodeProfile
# Ensure upstream so future 'git push' works
Set-BranchUpstream -LocalBranch $localBranch -RemoteName $RemoteName -RemoteBranchPath $ForkBranch
$after = Get-WorktreeEntries | Where-Object { $_.Branch -eq $localBranch }
$path = ($after | Select-Object -First 1).Path
Show-WorktreeExecutionSummary -CurrentBranch $localBranch -WorktreePath $path
Warn "Remote $RemoteName ready (URL: $forkUrl)"
$hasUp = git rev-parse --abbrev-ref --symbolic-full-name "$localBranch@{upstream}" 2>$null
if ($hasUp) { Info "Push with: git push (upstream: $hasUp)" } else { Warn 'Upstream not set; run: git push -u <remote> <local>:<remoteBranch>' }
} catch {
Err "Error: $($_.Exception.Message)"
Warn 'Manual steps:'
Info " git remote add temp-fork $forkUrl"
Info " git fetch temp-fork"
Info " git branch --track fork-<user>-<branch> temp-fork/$ForkBranch"
Info ' git worktree add ../<Repo>-XX fork-<user>-<branch>'
Info ' code ../<Repo>-XX'
exit 1
}

View File

@@ -0,0 +1,4 @@
@echo off
setlocal
set SCRIPT_DIR=%~dp0
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromIssue.ps1" %*

View File

@@ -0,0 +1,78 @@
<#!
.SYNOPSIS
Create (or reuse) a worktree for a new issue branch derived from a base ref.
.DESCRIPTION
Composes a branch name as issue/<number> or issue/<number>-<slug> (slug from optional -Title).
If the branch does not already exist, it is created from -Base (default origin/main). Then a
worktree is created or reused.
.PARAMETER Number
Issue number used to construct the branch name.
.PARAMETER Title
Optional descriptive title; slug into the branch name.
.PARAMETER Base
Base ref to branch from (default origin/main).
.PARAMETER VSCodeProfile
VS Code profile to open (Default).
.EXAMPLE
./New-WorktreeFromIssue.ps1 -Number 1234 -Title "Crash on launch"
.EXAMPLE
./New-WorktreeFromIssue.ps1 -Number 42 -Base origin/develop
.NOTES
Manual recovery:
git fetch origin
git checkout -b issue/<num>-<slug> <base>
git worktree add ../Repo-XX issue/<num>-<slug>
code ../Repo-XX
#>
param(
[int] $Number,
[string] $Title,
[string] $Base = 'origin/main',
[Alias('Profile')][string] $VSCodeProfile = 'Default',
[switch] $Help
)
. "$PSScriptRoot/WorktreeLib.ps1"
$scriptPath = $MyInvocation.MyCommand.Path
if ($Help -or -not $Number) { Show-FileEmbeddedHelp -ScriptPath $scriptPath; return }
# Compose branch name
if ($Title) {
$slug = ($Title -replace '[^\w\- ]','').ToLower() -replace ' +','-'
$branch = "issue/$Number-$slug"
} else {
$branch = "issue/$Number"
}
try {
# Create branch if missing
git show-ref --verify --quiet "refs/heads/$branch"
if ($LASTEXITCODE -ne 0) {
Info "Creating branch $branch from $Base"
git branch $branch $Base 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Failed to create branch $branch from $Base" }
} else {
Info "Branch $branch already exists locally."
}
New-WorktreeForExistingBranch -Branch $branch -VSCodeProfile $VSCodeProfile
$after = Get-WorktreeEntries | Where-Object { $_.Branch -eq $branch }
$path = ($after | Select-Object -First 1).Path
Show-WorktreeExecutionSummary -CurrentBranch $branch -WorktreePath $path
} catch {
Err "Error: $($_.Exception.Message)"
Warn 'Manual steps:'
Info " git fetch origin"
Info " git checkout -b $branch $Base (if branch missing)"
Info " git worktree add ../<Repo>-XX $branch"
Info ' code ../<Repo>-XX'
exit 1
}

View File

@@ -0,0 +1,94 @@
# PowerToys Worktree Helper Scripts
This folder contains helper scripts to create and manage parallel Git worktree for developing multiple changes (including Copilot suggestions) concurrently without cloning the full repository each time.
## Why worktree?
Git worktree let you have several checkedout branches sharing a single `.git` object store. Benefits:
- Fast context switching: no re-clone, no duplicate large binary/object downloads.
- Lower disk usage versus multiple full clones.
- Keeps each change isolated in its own folder so you can run builds/tests independently.
- Enables working in parallel with Copilot generated branches (e.g., feature + quick fix + perf experiment) while the main clone stays clean.
Recommended: keep active parallel worktree(s) to **≤ 3** per developer to reduce cognitive load and avoid excessive incremental build invalidations.
## Scripts Overview
| Script | Purpose |
|--------|---------|
| `New-WorktreeFromFork.ps1/.cmd` | Create a worktree from a branch in a personal fork (`<User>:<branch>` spec). Adds a temporary unique remote (e.g. `fork-abc12`). |
| `New-WorktreeFromBranch.ps1/.cmd` | Create/reuse a worktree for an existing local or remote (origin) branch. Can normalize `origin/branch` to `branch`. |
| `New-WorktreeFromIssue.ps1/.cmd` | Start a new issue branch from a base (default `origin/main`) using naming `issue/<number>-<slug>`. |
| `Delete-Worktree.ps1/.cmd` | Remove a worktree and optionally its local branch / orphan fork remote. |
| `WorktreeLib.ps1` | Shared helpers: unique folder naming, worktree listing, upstream setup, summary output, logging helpers. |
## Typical Flows
### 1. Create from a fork branch
```
./New-WorktreeFromFork.ps1 -Spec alice:feature/perf-tweak
```
Creates remote `fork-xxxxx`, fetches just that branch, creates local branch `fork-alice-feature-perf-tweak`, makes a new worktree beside the repo root.
### 2. Create from an existing or remote branch
```
./New-WorktreeFromBranch.ps1 -Branch origin/feature/new-ui
```
Fetches if needed and creates a tracking branch if missing, then creates/reuses the worktree.
### 3. Start a new issue branch
```
./New-WorktreeFromIssue.ps1 -Number 1234 -Title "Crash on launch"
```
Creates branch `issue/1234-crash-on-launch` off `origin/main` (or `-Base`), then worktree.
### 4. Delete a worktree when done
```
./Delete-Worktree.ps1 -Pattern feature/perf-tweak
```
If only one match, removes the worktree directory. Add `-Force` to discard local changes. Use `-KeepBranch` if you still need the branch, `-KeepRemote` to retain a fork remote.
## After Creating a Worktree
Inside the new worktree directory:
1. Run the minimal build bootstrap in VSCode terminal:
```
tools\build\build-essentials.cmd
```
2. Build only the module(s) you need (e.g., open solution filter or run targeted project build) instead of a full PowerToys build. This speeds iteration and reduces noise.
3. Make changes, commit, push.
4. Finally delete the worktree when done.
## Naming & Locations
- Worktree is created as sibling folders of the repo root (e.g., `PowerToys` + `PowerToys-ab12`), using a hash/short pattern to avoid collisions.
- Fork-based branches get local names `fork-<user>-<sanitized-branch>`.
- Issue branches: `issue/<number>` or `issue/<number>-<slug>`.
## Scenarios Covered / Limitations
Covered scenarios:
1. From a fork branch (personal fork on GitHub).
2. From an existing local or origin remote branch.
3. Creating a new branch for an issue.
Not covered (manual steps needed):
- Creating from a non-origin upstream other than a fork (add remote manually then use branch script).
- Batch creation of multiple worktree in one command.
- Automatic rebase / sync of many worktree at once (do that manually or script separately).
## Best Practices
- Keep ≤ 3 active parallel worktree(s) (e.g., main dev, a long-lived feature, a quick fix / experiment) plus the root clone.
- Delete stale worktree early; each adds file watchers & potential incremental build churn.
- Avoid editing the same file across multiple worktree simultaneously to reduce merge friction.
- Run `git fetch --all --prune` periodically in the primary repo, not in every worktree.
## Troubleshooting
| Symptom | Hint |
|---------|------|
| Fetch failed for fork remote | Branch name typo or fork private without auth. Try manual `git fetch <remote> <branch>`.
| Cannot lock ref *.lock | Stale lock: run `git worktree prune` or manually delete the `.lock` file then retry.
| Worktree already exists error | Use `git worktree list` to locate existing path; open that folder instead of creating a duplicate.
| Local branch missing for remote | Use `git branch --track <name> origin/<name>` then re-run the branch script.
## Security & Safety Notes
- Scripts avoid force-deleting unless you pass `-Force` (Delete script).
- No network credentials are stored; they rely on your existing Git credential helper.
- Always review a new fork remote URL before pushing.
---
Maintainers: Keep the scripts lean; avoid adding heavy dependencies or global state. Update this doc if parameters or flows change.

151
tools/build/WorktreeLib.ps1 Normal file
View File

@@ -0,0 +1,151 @@
# 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" }
}