Created
February 17, 2026 17:19
-
-
Save timothywarner/380e9000ce7cd5ae43078d4c55a6ebbe to your computer and use it in GitHub Desktop.
Git worktree helpers for parallel Claude Code sessions — Start-ClaudeWorktree / Stop-ClaudeWorktree (PowerShell 7+)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #Requires -Version 7.0 | |
| <# | |
| .SYNOPSIS | |
| Git worktree helpers for parallel Claude Code sessions. | |
| .DESCRIPTION | |
| Two functions that eliminate friction when spinning up (and tearing down) | |
| git worktrees for parallel Claude Code work. Designed for solo developers | |
| who want true parallelism without branch-naming ceremony. | |
| Start-ClaudeWorktree : Creates a sibling worktree and launches Claude Code in it. | |
| Stop-ClaudeWorktree : Merges the worktree branch back, removes the worktree, cleans up. | |
| Prerequisites: | |
| - PowerShell 7+ (pwsh) https://aka.ms/powershell | |
| - Git 2.20+ https://git-scm.com | |
| - Windows Terminal (wt.exe) Ships with Windows 11; optional on Win 10 | |
| - Claude Code CLI (claude) https://docs.anthropic.com/en/docs/claude-code | |
| Setup: | |
| Add to your $PROFILE with: . "C:\path\to\ClaudeWorktree.ps1" | |
| Quick start: | |
| Start-ClaudeWorktree # timestamp-named worktree + new tab | |
| Start-ClaudeWorktree -Name "my-spike" # descriptive name | |
| Stop-ClaudeWorktree # merge, remove worktree, delete branch | |
| Stop-ClaudeWorktree -NoMerge # discard worktree without merging | |
| .NOTES | |
| Author : Tim Warner (TechTrainerTim.com) | |
| Version: 1.1.0 | |
| License: MIT | |
| #> | |
| function Start-ClaudeWorktree { | |
| <# | |
| .SYNOPSIS | |
| Creates a git worktree sibling and launches Claude Code in it. | |
| .DESCRIPTION | |
| Run this from inside any git repo. It creates a worktree as a sibling | |
| directory (../reponame-wt-<suffix>) on an auto-named branch. Then it | |
| opens a new Windows Terminal tab with Claude Code running in that worktree. | |
| Branch names auto-generate from a timestamp by default, so you never | |
| have to think about it. Pass -Name if you want something descriptive. | |
| .PARAMETER Name | |
| Optional short label for the worktree/branch. Defaults to a timestamp | |
| like "wt-20260214-143025". Only a-z, 0-9, and hyphens are kept. | |
| .PARAMETER NoLaunch | |
| Creates the worktree but doesn't auto-launch Claude Code. Useful when | |
| you want to cd into it yourself or run a different tool. | |
| .EXAMPLE | |
| Start-ClaudeWorktree | |
| # Creates ../myrepo-wt-20260217-143025, launches Claude Code in new tab | |
| .EXAMPLE | |
| Start-ClaudeWorktree -Name "auth-refactor" | |
| # Creates ../myrepo-wt-auth-refactor, launches Claude Code in new tab | |
| .EXAMPLE | |
| Start-ClaudeWorktree -Name "spike" -NoLaunch | |
| # Creates the worktree but stays in current terminal | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Position = 0)] | |
| [string]$Name, | |
| [switch]$NoLaunch | |
| ) | |
| # --- Guard: make sure we're inside a git repo --- | |
| if (-not (git rev-parse --is-inside-work-tree 2>$null)) { | |
| Write-Error "Not inside a git repo. cd into one first." | |
| return | |
| } | |
| # --- Guard: ensure working tree is clean to avoid merge pain later --- | |
| $status = git status --porcelain | |
| if ($status) { | |
| Write-Warning "You have uncommitted changes. Commit or stash first to keep merges clean." | |
| Write-Warning "Continuing anyway — but you've been warned." | |
| } | |
| # --- Resolve repo name and parent directory for sibling placement --- | |
| $repoRoot = git rev-parse --show-toplevel | |
| $repoName = Split-Path $repoRoot -Leaf | |
| $parentDir = Split-Path $repoRoot -Parent | |
| # --- Generate the worktree suffix and branch name --- | |
| # Default: timestamp-based so you never have to think about it | |
| if ([string]::IsNullOrWhiteSpace($Name)) { | |
| $suffix = "wt-$(Get-Date -Format 'yyyyMMdd-HHmmss')" | |
| } | |
| else { | |
| # Sanitize: whitelist alphanumeric + hyphens only, collapse runs of hyphens | |
| $sanitized = ($Name.ToLower() -replace '[^a-z0-9\-]', '-') -replace '-{2,}', '-' | |
| $sanitized = $sanitized.Trim('-') | |
| if ([string]::IsNullOrEmpty($sanitized)) { | |
| Write-Error "Name '$Name' contains no valid characters after sanitization (only a-z, 0-9, hyphens allowed)." | |
| return | |
| } | |
| $suffix = "wt-$sanitized" | |
| } | |
| $worktreePath = Join-Path $parentDir "$repoName-$suffix" | |
| $branchName = "worktree/$suffix" | |
| # --- Guard: don't clobber an existing worktree at that path --- | |
| if (Test-Path $worktreePath) { | |
| Write-Error "Path already exists: $worktreePath — pick a different name or remove it first." | |
| return | |
| } | |
| # --- Create the worktree with a new branch off current HEAD --- | |
| Write-Host "Creating worktree at: " -NoNewline | |
| Write-Host $worktreePath -ForegroundColor Cyan | |
| Write-Host "Branch: " -NoNewline | |
| Write-Host $branchName -ForegroundColor Cyan | |
| git worktree add "$worktreePath" -b "$branchName" | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Error "git worktree add failed. Check the output above." | |
| return | |
| } | |
| Write-Host "Worktree ready." -ForegroundColor Green | |
| # --- Launch Claude Code in a new Windows Terminal tab --- | |
| if (-not $NoLaunch) { | |
| if (-not (Get-Command claude -ErrorAction SilentlyContinue)) { | |
| Write-Warning "Claude CLI not found on PATH. The new tab may fail to launch Claude Code." | |
| } | |
| # wt.exe is Windows Terminal's CLI — ships with Win 11 | |
| # -w 0 = current window, nt = new tab, -d = starting directory | |
| $wtArgs = @('-w', '0', 'nt', '-d', $worktreePath, '--title', "Claude: $suffix", 'pwsh', '-NoExit', '-Command', 'claude') | |
| try { | |
| Start-Process wt.exe -ArgumentList $wtArgs | |
| Write-Host "Claude Code launching in new tab..." -ForegroundColor Green | |
| } | |
| catch { | |
| # Fallback: if wt.exe isn't available, just tell them where to go | |
| Write-Warning "Couldn't launch Windows Terminal tab. Open a new terminal and run:" | |
| Write-Host " cd '$worktreePath' && claude" -ForegroundColor Yellow | |
| } | |
| } | |
| else { | |
| Write-Host "Worktree created (no auto-launch). To use it:" -ForegroundColor Yellow | |
| Write-Host " cd '$worktreePath'" -ForegroundColor Yellow | |
| Write-Host " claude" -ForegroundColor Yellow | |
| } | |
| # --- Stash the worktree metadata in .git so it never pollutes the working tree --- | |
| $gitDir = (git rev-parse --git-common-dir) | |
| $metaDir = Join-Path $gitDir 'claude-worktrees' | |
| New-Item -ItemType Directory -Path $metaDir -Force | Out-Null | |
| $metadata = @{ | |
| SourceRepo = $repoRoot | |
| BranchName = $branchName | |
| CreatedAt = (Get-Date -Format 'o') | |
| WorktreePath = $worktreePath | |
| Suffix = $suffix | |
| } | |
| $metadata | ConvertTo-Json | Set-Content (Join-Path $metaDir "$suffix.json") -Force -Encoding utf8 | |
| Write-Host "`nWhen done, run " -NoNewline | |
| Write-Host "Stop-ClaudeWorktree" -ForegroundColor Cyan -NoNewline | |
| Write-Host " from inside the worktree to merge and clean up." | |
| } | |
| function Stop-ClaudeWorktree { | |
| <# | |
| .SYNOPSIS | |
| Merges a worktree branch back to its source and removes the worktree. | |
| .DESCRIPTION | |
| Run this from INSIDE a worktree created by Start-ClaudeWorktree — or | |
| from the main repo. Smart location detection handles three scenarios: | |
| 1. You're in a worktree (ideal) — proceeds immediately. | |
| 2. You're in the main repo with one worktree — auto-switches to it. | |
| 3. You're in the main repo with multiple worktrees — shows a picker. | |
| Reads metadata from .git/claude-worktrees/, switches back to the | |
| source repo, merges the branch, removes the worktree, and deletes | |
| the branch. One command, full cleanup. | |
| .PARAMETER NoMerge | |
| Removes the worktree without merging. Use when you want to discard | |
| the work (spike/experiment that didn't pan out). | |
| .EXAMPLE | |
| Stop-ClaudeWorktree | |
| # Merges branch into source, removes worktree, deletes branch | |
| .EXAMPLE | |
| Stop-ClaudeWorktree -NoMerge | |
| # Discards the worktree entirely — no merge, just cleanup | |
| #> | |
| [CmdletBinding(SupportsShouldProcess)] | |
| param( | |
| [switch]$NoMerge | |
| ) | |
| # --- Smart location detection: find the main repo and its metadata --- | |
| $repoRoot = git rev-parse --show-toplevel 2>$null | |
| if (-not $repoRoot) { | |
| Write-Error "Not inside a git repo. cd into a worktree or repo first." | |
| return | |
| } | |
| # Resolve the main repo's .git directory (works from both main repo and worktrees) | |
| $commonGitDir = git rev-parse --git-common-dir 2>$null | |
| if (-not $commonGitDir -or -not (Test-Path $commonGitDir)) { | |
| Write-Error "Cannot locate the main .git directory." | |
| return | |
| } | |
| $metaDir = Join-Path $commonGitDir 'claude-worktrees' | |
| $metaFiles = @() | |
| if (Test-Path $metaDir) { | |
| $metaFiles = @(Get-ChildItem -Path $metaDir -Filter '*.json' -File) | |
| } | |
| if ($metaFiles.Count -eq 0) { | |
| Write-Error "No active worktrees found. Run Start-ClaudeWorktree first." | |
| return | |
| } | |
| # Determine if we're inside a tracked worktree already | |
| $currentPath = (Get-Location).Path | |
| $matchedMeta = $null | |
| foreach ($mf in $metaFiles) { | |
| $candidate = Get-Content $mf.FullName -Raw -Encoding utf8 | ConvertFrom-Json | |
| if ($currentPath -eq $candidate.WorktreePath -or | |
| $currentPath.StartsWith("$($candidate.WorktreePath)\") -or | |
| $currentPath.StartsWith("$($candidate.WorktreePath)/")) { | |
| $matchedMeta = $candidate | |
| $matchedMetaFile = $mf.FullName | |
| break | |
| } | |
| } | |
| if ($matchedMeta) { | |
| # SCENARIO 1: We're inside a tracked worktree — proceed | |
| Write-Host "Found worktree metadata. Proceeding with cleanup." -ForegroundColor Green | |
| } | |
| elseif ($metaFiles.Count -eq 1) { | |
| # SCENARIO 2a: In main repo, found exactly one worktree — auto-switch | |
| $matchedMetaFile = $metaFiles[0].FullName | |
| $matchedMeta = Get-Content $matchedMetaFile -Raw -Encoding utf8 | ConvertFrom-Json | |
| Write-Warning "You're in the main repo, not the worktree." | |
| Write-Host "Found one active worktree: " -NoNewline | |
| Write-Host $matchedMeta.WorktreePath -ForegroundColor Cyan | |
| Write-Host "Switching to it automatically..." -ForegroundColor Yellow | |
| Set-Location $matchedMeta.WorktreePath | |
| } | |
| else { | |
| # SCENARIO 2b: In main repo, multiple worktrees — picker | |
| Write-Warning "You're in the main repo, not a worktree. Found $($metaFiles.Count) active worktrees:" | |
| Write-Host "" | |
| $index = 1 | |
| $allMeta = [System.Collections.Generic.List[hashtable]]::new() | |
| foreach ($mf in $metaFiles) { | |
| $wtMeta = Get-Content $mf.FullName -Raw -Encoding utf8 | ConvertFrom-Json | |
| $allMeta.Add(@{ Meta = $wtMeta; File = $mf.FullName }) | |
| Write-Host " [$index] " -ForegroundColor Cyan -NoNewline | |
| Write-Host "$($wtMeta.Suffix)" -NoNewline | |
| Write-Host " (branch: $($wtMeta.BranchName), created: $($wtMeta.CreatedAt))" -ForegroundColor DarkGray | |
| $index++ | |
| } | |
| Write-Host "" | |
| $choice = Read-Host "Enter number to stop (or 'q' to cancel)" | |
| if ($choice -eq 'q' -or [string]::IsNullOrWhiteSpace($choice)) { | |
| Write-Host "Cancelled." -ForegroundColor Yellow | |
| return | |
| } | |
| $choiceInt = 0 | |
| if (-not [int]::TryParse($choice, [ref]$choiceInt)) { | |
| Write-Error "Invalid input '$choice'. Enter a number between 1 and $($metaFiles.Count)." | |
| return | |
| } | |
| $choiceIndex = $choiceInt - 1 | |
| if ($choiceIndex -lt 0 -or $choiceIndex -ge $metaFiles.Count) { | |
| Write-Error "Invalid selection. Enter a number between 1 and $($metaFiles.Count)." | |
| return | |
| } | |
| $selected = $allMeta[$choiceIndex] | |
| $matchedMeta = $selected.Meta | |
| $matchedMetaFile = $selected.File | |
| Write-Host "Switching to: $($matchedMeta.WorktreePath)" -ForegroundColor Yellow | |
| Set-Location $matchedMeta.WorktreePath | |
| } | |
| # --- Read and validate the metadata so we know where to merge back --- | |
| $requiredFields = @('SourceRepo', 'BranchName', 'WorktreePath') | |
| foreach ($field in $requiredFields) { | |
| if ([string]::IsNullOrWhiteSpace($matchedMeta.$field)) { | |
| Write-Error "Corrupted metadata: missing '$field' in worktree tracking file." | |
| return | |
| } | |
| } | |
| $sourceRepo = $matchedMeta.SourceRepo | |
| $branchName = $matchedMeta.BranchName | |
| $worktreePath = $matchedMeta.WorktreePath | |
| if (-not (Test-Path $sourceRepo)) { | |
| Write-Error "Source repo no longer exists at '$sourceRepo'. Was it moved or deleted?" | |
| return | |
| } | |
| Write-Host "Worktree : $worktreePath" -ForegroundColor Cyan | |
| Write-Host "Branch : $branchName" -ForegroundColor Cyan | |
| Write-Host "Source : $sourceRepo" -ForegroundColor Cyan | |
| # --- Guard: check for uncommitted work --- | |
| $status = git status --porcelain | |
| if ($status) { | |
| Write-Warning "Uncommitted changes detected in this worktree!" | |
| if (-not $NoMerge) { | |
| # Auto-commit everything so the merge actually captures the work | |
| # Claude Code often leaves files in a working-but-uncommitted state | |
| if ($PSCmdlet.ShouldProcess('all uncommitted changes', 'Auto-commit before merge')) { | |
| Write-Host "Auto-committing all changes so nothing is lost..." -ForegroundColor Yellow | |
| git add -A | |
| git commit -m "wip: auto-commit from Stop-ClaudeWorktree before merge" | |
| } | |
| } | |
| else { | |
| Write-Warning "Uncommitted changes will be LOST with -NoMerge. Proceeding anyway." | |
| } | |
| } | |
| # --- Switch back to the source repo (skip during -WhatIf to avoid side effects) --- | |
| if (-not $WhatIfPreference) { | |
| Set-Location $sourceRepo | |
| } | |
| # --- Merge (unless -NoMerge) --- | |
| if (-not $NoMerge) { | |
| if ($PSCmdlet.ShouldProcess($branchName, 'Merge branch into current')) { | |
| Write-Host "`nMerging $branchName into current branch..." -ForegroundColor Green | |
| git merge "$branchName" --no-edit | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Error "Merge conflict detected. Resolve conflicts in $sourceRepo, then manually run:" | |
| Write-Host " git worktree remove '$worktreePath'" -ForegroundColor Yellow | |
| Write-Host " git branch -d '$branchName'" -ForegroundColor Yellow | |
| return | |
| } | |
| Write-Host "Merge successful." -ForegroundColor Green | |
| } | |
| } | |
| else { | |
| Write-Host "`nSkipping merge (-NoMerge specified)." -ForegroundColor Yellow | |
| } | |
| # --- Remove the worktree (--force only when discarding with -NoMerge) --- | |
| if ($PSCmdlet.ShouldProcess($worktreePath, 'Remove worktree')) { | |
| Write-Host "Removing worktree..." -ForegroundColor Yellow | |
| if ($NoMerge) { | |
| git worktree remove "$worktreePath" --force | |
| } | |
| else { | |
| git worktree remove "$worktreePath" | |
| } | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Warning "git worktree remove had issues. You may need to manually delete: $worktreePath" | |
| } | |
| } | |
| # --- Delete the branch (it's merged or unwanted, either way it's done) --- | |
| if ($PSCmdlet.ShouldProcess($branchName, 'Delete branch')) { | |
| if (-not $NoMerge) { | |
| git branch -d "$branchName" 2>$null # -d = safe delete (only if merged) | |
| } | |
| else { | |
| git branch -D "$branchName" 2>$null # -D = force delete (discard unmerged work) | |
| } | |
| } | |
| # --- Remove the metadata breadcrumb from .git/claude-worktrees/ --- | |
| if ($PSCmdlet.ShouldProcess($matchedMetaFile, 'Remove worktree metadata')) { | |
| if ($matchedMetaFile -and (Test-Path $matchedMetaFile)) { | |
| Remove-Item $matchedMetaFile -Force | |
| } | |
| } | |
| Write-Host "`nClean. You're back in $sourceRepo on $(git branch --show-current)." -ForegroundColor Green | |
| } | |
| # When dot-sourced, all functions are automatically available in the caller's scope. | |
| # To use as a module, rename to ClaudeWorktree.psm1 and uncomment: | |
| # Export-ModuleMember -Function Start-ClaudeWorktree, Stop-ClaudeWorktree |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment