diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 8a8b123805..86bb9fc1d4 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -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 diff --git a/tools/build/Delete-Worktree.cmd b/tools/build/Delete-Worktree.cmd new file mode 100644 index 0000000000..edf14bb537 --- /dev/null +++ b/tools/build/Delete-Worktree.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +set SCRIPT_DIR=%~dp0 +pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%Delete-Worktree.ps1" %* diff --git a/tools/build/Delete-Worktree.ps1 b/tools/build/Delete-Worktree.ps1 new file mode 100644 index 0000000000..68f7c218d4 --- /dev/null +++ b/tools/build/Delete-Worktree.ps1 @@ -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 -Recurse -Force + git branch -D + git remote remove + 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 '^(?[^/]+)/'){ $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 -Recurse -Force' + Info ' git branch -D (if you also want to drop local branch)' + Info ' git remote remove (if orphan fork remote remains)' + Info ' git worktree prune' + exit 1 +} diff --git a/tools/build/New-WorktreeFromBranch.cmd b/tools/build/New-WorktreeFromBranch.cmd new file mode 100644 index 0000000000..a1c2b9a624 --- /dev/null +++ b/tools/build/New-WorktreeFromBranch.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +set SCRIPT_DIR=%~dp0 +pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromBranch.ps1" %* diff --git a/tools/build/New-WorktreeFromBranch.ps1 b/tools/build/New-WorktreeFromBranch.ps1 new file mode 100644 index 0000000000..d299e1a879 --- /dev/null +++ b/tools/build/New-WorktreeFromBranch.ps1 @@ -0,0 +1,78 @@ +<#! +.SYNOPSIS + Create (or reuse) a worktree for an existing local or remote (origin) branch. + +.DESCRIPTION + Normalizes origin/ to . 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/ 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 + git worktree add ../RepoName-XX + 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/ to +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 ../-XX ' + Info ' code ../-XX' + exit 1 +} diff --git a/tools/build/New-WorktreeFromFork.cmd b/tools/build/New-WorktreeFromFork.cmd new file mode 100644 index 0000000000..be8bc05c0f --- /dev/null +++ b/tools/build/New-WorktreeFromFork.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +set SCRIPT_DIR=%~dp0 +pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromFork.ps1" %* diff --git a/tools/build/New-WorktreeFromFork.ps1 b/tools/build/New-WorktreeFromFork.ps1 new file mode 100644 index 0000000000..ccd26631e4 --- /dev/null +++ b/tools/build/New-WorktreeFromFork.ps1 @@ -0,0 +1,127 @@ +<#! +.SYNOPSIS + Create (or reuse) a worktree from a branch in a personal fork: :. + +.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-- or custom alias), and delegates worktree creation/reuse + to shared helpers in WorktreeLib. + +.PARAMETER Spec + Fork spec in the form :. + +.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--. + +.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//.git + git fetch fork-temp + git branch --track fork-- fork-temp/ + git worktree add ../Repo-XX fork-- + 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 :, 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 :' } +} 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-- temp-fork/$ForkBranch" + Info ' git worktree add ../-XX fork--' + Info ' code ../-XX' + exit 1 +} diff --git a/tools/build/New-WorktreeFromIssue.cmd b/tools/build/New-WorktreeFromIssue.cmd new file mode 100644 index 0000000000..6aba21652c --- /dev/null +++ b/tools/build/New-WorktreeFromIssue.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +set SCRIPT_DIR=%~dp0 +pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromIssue.ps1" %* diff --git a/tools/build/New-WorktreeFromIssue.ps1 b/tools/build/New-WorktreeFromIssue.ps1 new file mode 100644 index 0000000000..c5523fcd13 --- /dev/null +++ b/tools/build/New-WorktreeFromIssue.ps1 @@ -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/ or issue/- (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/- + git worktree add ../Repo-XX issue/- + 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 ../-XX $branch" + Info ' code ../-XX' + exit 1 +} diff --git a/tools/build/Worktree-Guidelines.md b/tools/build/Worktree-Guidelines.md new file mode 100644 index 0000000000..bccd80ab9f --- /dev/null +++ b/tools/build/Worktree-Guidelines.md @@ -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 checked‑out 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 (`:` 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/-`. | +| `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--`. +- Issue branches: `issue/` or `issue/-`. + +## 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 `. +| 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 origin/` 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. diff --git a/tools/build/WorktreeLib.ps1 b/tools/build/WorktreeLib.ps1 new file mode 100644 index 0000000000..01883115d1 --- /dev/null +++ b/tools/build/WorktreeLib.ps1 @@ -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 '^(?[^/]+)/.+$') { 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 ' + 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" } +}