mirror of
https://github.com/microsoft/PowerToys.git
synced 2025-12-15 19:27:56 +01:00
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:
3
.github/actions/spell-check/expect.txt
vendored
3
.github/actions/spell-check/expect.txt
vendored
@@ -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
|
||||
|
||||
4
tools/build/Delete-Worktree.cmd
Normal file
4
tools/build/Delete-Worktree.cmd
Normal file
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set SCRIPT_DIR=%~dp0
|
||||
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%Delete-Worktree.ps1" %*
|
||||
130
tools/build/Delete-Worktree.ps1
Normal file
130
tools/build/Delete-Worktree.ps1
Normal 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
|
||||
}
|
||||
4
tools/build/New-WorktreeFromBranch.cmd
Normal file
4
tools/build/New-WorktreeFromBranch.cmd
Normal file
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set SCRIPT_DIR=%~dp0
|
||||
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromBranch.ps1" %*
|
||||
78
tools/build/New-WorktreeFromBranch.ps1
Normal file
78
tools/build/New-WorktreeFromBranch.ps1
Normal 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
|
||||
}
|
||||
4
tools/build/New-WorktreeFromFork.cmd
Normal file
4
tools/build/New-WorktreeFromFork.cmd
Normal file
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set SCRIPT_DIR=%~dp0
|
||||
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromFork.ps1" %*
|
||||
127
tools/build/New-WorktreeFromFork.ps1
Normal file
127
tools/build/New-WorktreeFromFork.ps1
Normal 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
|
||||
}
|
||||
4
tools/build/New-WorktreeFromIssue.cmd
Normal file
4
tools/build/New-WorktreeFromIssue.cmd
Normal file
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set SCRIPT_DIR=%~dp0
|
||||
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromIssue.ps1" %*
|
||||
78
tools/build/New-WorktreeFromIssue.ps1
Normal file
78
tools/build/New-WorktreeFromIssue.ps1
Normal 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
|
||||
}
|
||||
94
tools/build/Worktree-Guidelines.md
Normal file
94
tools/build/Worktree-Guidelines.md
Normal 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 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 (`<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
151
tools/build/WorktreeLib.ps1
Normal 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" }
|
||||
}
|
||||
Reference in New Issue
Block a user