Skip to content

Instantly share code, notes, and snippets.

@timothywarner
Created February 17, 2026 17:19
Show Gist options
  • Select an option

  • Save timothywarner/380e9000ce7cd5ae43078d4c55a6ebbe to your computer and use it in GitHub Desktop.

Select an option

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+)
#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