From 2bfde6ed501f38ef3ff674b9f229bd39c46f56d0 Mon Sep 17 00:00:00 2001 From: James Casey Date: Wed, 4 Mar 2026 09:01:59 +0000 Subject: [PATCH 1/2] feat: restructure as PowerShell module with cmdlets - Add MultiCopilot module (psd1, psm1, loader) - Convert scripts to cmdlets: New-CopilotWorktree, Remove-CopilotWorktree, Get-CopilotWorktree, Clear-CopilotWorktree - Add Initialize-CopilotProject cmdlet to scaffold .devcontainer/ templates - Extract shared helpers to Private/GitHelpers.ps1 - Bundle template files in MultiCopilot/Templates/ - Add SupportsShouldProcess (-WhatIf/-Confirm) to destructive cmdlets - Move AMP_API_KEY validation to fail-fast (before container setup) - Remove old scripts/ directory Amp-Thread-ID: https://ampcode.com/threads/T-019cb806-b11a-70be-8c9b-1fb00a8b8ee2 Co-authored-by: Amp --- MultiCopilot/MultiCopilot.psd1 | 34 + MultiCopilot/MultiCopilot.psm1 | 26 + MultiCopilot/Private/GitHelpers.ps1 | 233 ++++++ MultiCopilot/Public/Clear-CopilotWorktree.ps1 | 271 +++++++ MultiCopilot/Public/Get-CopilotWorktree.ps1 | 105 +++ .../Public/Initialize-CopilotProject.ps1 | 155 ++++ MultiCopilot/Public/New-CopilotWorktree.ps1 | 553 ++++++++++++++ .../Public/Remove-CopilotWorktree.ps1 | 222 ++++++ .../Templates/.devcontainer}/Dockerfile | 0 .../.devcontainer}/README-copilot-config.md | 0 .../.devcontainer}/copilot-config.json | 0 .../.devcontainer}/devcontainer.json | 0 .../Templates/.devcontainer}/mcp-config.json | 0 .../.devcontainer}/setup-git-auth.sh | 0 MultiCopilot/Templates/.gitattributes | 13 + .../Templates}/smoke-test.sh | 0 README.md | 167 +++-- scripts/README.md | 173 ----- scripts/worktree-cleanup.ps1 | 260 ------- scripts/worktree-down.ps1 | 333 --------- scripts/worktree-status.ps1 | 168 ----- scripts/worktree-up.ps1 | 695 ------------------ 22 files changed, 1731 insertions(+), 1677 deletions(-) create mode 100644 MultiCopilot/MultiCopilot.psd1 create mode 100644 MultiCopilot/MultiCopilot.psm1 create mode 100644 MultiCopilot/Private/GitHelpers.ps1 create mode 100644 MultiCopilot/Public/Clear-CopilotWorktree.ps1 create mode 100644 MultiCopilot/Public/Get-CopilotWorktree.ps1 create mode 100644 MultiCopilot/Public/Initialize-CopilotProject.ps1 create mode 100644 MultiCopilot/Public/New-CopilotWorktree.ps1 create mode 100644 MultiCopilot/Public/Remove-CopilotWorktree.ps1 rename {.devcontainer => MultiCopilot/Templates/.devcontainer}/Dockerfile (100%) rename {.devcontainer => MultiCopilot/Templates/.devcontainer}/README-copilot-config.md (100%) rename {.devcontainer => MultiCopilot/Templates/.devcontainer}/copilot-config.json (100%) rename {.devcontainer => MultiCopilot/Templates/.devcontainer}/devcontainer.json (100%) rename {.devcontainer => MultiCopilot/Templates/.devcontainer}/mcp-config.json (100%) rename {.devcontainer => MultiCopilot/Templates/.devcontainer}/setup-git-auth.sh (100%) create mode 100644 MultiCopilot/Templates/.gitattributes rename {scripts => MultiCopilot/Templates}/smoke-test.sh (100%) delete mode 100644 scripts/README.md delete mode 100644 scripts/worktree-cleanup.ps1 delete mode 100644 scripts/worktree-down.ps1 delete mode 100644 scripts/worktree-status.ps1 delete mode 100644 scripts/worktree-up.ps1 diff --git a/MultiCopilot/MultiCopilot.psd1 b/MultiCopilot/MultiCopilot.psd1 new file mode 100644 index 0000000..17c1094 --- /dev/null +++ b/MultiCopilot/MultiCopilot.psd1 @@ -0,0 +1,34 @@ +# Copyright 2026 James Casey +# SPDX-License-Identifier: Apache-2.0 + +@{ + RootModule = 'MultiCopilot.psm1' + ModuleVersion = '0.1.0' + GUID = 'a3c8f1e2-7d4b-4a9e-b6c5-1f2e3d4a5b6c' + Author = 'James Casey' + CompanyName = '' + Copyright = 'Copyright 2026 James Casey. Apache-2.0 license.' + Description = 'Run multiple parallel GitHub Copilot CLI sessions using git worktrees and devcontainers.' + + PowerShellVersion = '5.1' + + FunctionsToExport = @( + 'Initialize-CopilotProject' + 'New-CopilotWorktree' + 'Remove-CopilotWorktree' + 'Get-CopilotWorktree' + 'Clear-CopilotWorktree' + ) + + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + + PrivateData = @{ + PSData = @{ + Tags = @('copilot', 'devcontainer', 'worktree', 'git', 'parallel') + LicenseUri = 'https://github.com/jamesc/multi-copilot/blob/main/LICENSE' + ProjectUri = 'https://github.com/jamesc/multi-copilot' + } + } +} diff --git a/MultiCopilot/MultiCopilot.psm1 b/MultiCopilot/MultiCopilot.psm1 new file mode 100644 index 0000000..36bb07d --- /dev/null +++ b/MultiCopilot/MultiCopilot.psm1 @@ -0,0 +1,26 @@ +# Copyright 2026 James Casey +# SPDX-License-Identifier: Apache-2.0 + +# MultiCopilot Module Loader +# Dot-source all private helpers and public cmdlets + +$ModuleRoot = $PSScriptRoot + +# Import private helpers +foreach ($file in Get-ChildItem -Path "$ModuleRoot/Private" -Filter '*.ps1' -ErrorAction SilentlyContinue) { + . $file.FullName +} + +# Import public cmdlets +foreach ($file in Get-ChildItem -Path "$ModuleRoot/Public" -Filter '*.ps1' -ErrorAction SilentlyContinue) { + . $file.FullName +} + +# Export only public functions +Export-ModuleMember -Function @( + 'Initialize-CopilotProject' + 'New-CopilotWorktree' + 'Remove-CopilotWorktree' + 'Get-CopilotWorktree' + 'Clear-CopilotWorktree' +) diff --git a/MultiCopilot/Private/GitHelpers.ps1 b/MultiCopilot/Private/GitHelpers.ps1 new file mode 100644 index 0000000..a72c3b4 --- /dev/null +++ b/MultiCopilot/Private/GitHelpers.ps1 @@ -0,0 +1,233 @@ +# Copyright 2026 James Casey +# SPDX-License-Identifier: Apache-2.0 + +# Shared helper functions for MultiCopilot cmdlets + +function Get-MainRepoRoot { + <# + .SYNOPSIS + Find the main repo root (where .git is a directory, not a file). + #> + $current = Get-Location + + $gitPath = Join-Path $current ".git" + + if (Test-Path $gitPath -PathType Container) { + return $current.Path + } + elseif (Test-Path $gitPath -PathType Leaf) { + $gitContent = Get-Content $gitPath -Raw + if ($gitContent -match "gitdir:\s*(.+)") { + $gitDir = $matches[1].Trim() + # gitDir points to .git/worktrees/name, go up to .git then to repo + $mainGit = Split-Path (Split-Path $gitDir -Parent) -Parent + return Split-Path $mainGit -Parent + } + } + + $gitRoot = git rev-parse --show-toplevel 2>$null + if ($gitRoot) { + return $gitRoot + } + + throw "Could not find git repository root" +} + +function Get-DefaultBranch { + <# + .SYNOPSIS + Detect the default branch (main or master). + #> + $remoteBranch = git symbolic-ref refs/remotes/origin/HEAD 2>$null + if ($remoteBranch -match "refs/remotes/origin/(.+)") { + return $matches[1] + } + if (git rev-parse --verify main 2>$null) { + return "main" + } + return "master" +} + +function Test-BranchExists { + <# + .SYNOPSIS + Check if a branch exists locally or on remote. + #> + param([string]$BranchName) + + $local = git branch --list $BranchName 2>$null + if ($local) { return $true } + + $remote = git branch -r --list "origin/$BranchName" 2>$null + if ($remote) { return $true } + + return $false +} + +function Find-WorktreeForBranch { + <# + .SYNOPSIS + Find the worktree path for a branch by matching directory name. + #> + param( + [string]$BranchName, + [string]$WorktreeRoot + ) + + $dirName = $BranchName -replace '/', '-' + $expectedPath = Join-Path $WorktreeRoot $dirName + + $worktrees = git worktree list --porcelain 2>$null + $wtPath = $null + foreach ($line in $worktrees) { + if ($line -match "^worktree\s+(.+)") { + $wtPath = $matches[1] + + # Convert container path to host path if needed + if ($wtPath -match "^/workspaces/(.+)$") { + $folderName = $matches[1] + $wtPath = Join-Path $WorktreeRoot $folderName + } + + if ($wtPath -eq $expectedPath) { + return $wtPath + } + } + } + return $null +} + +function Get-ProjectName { + <# + .SYNOPSIS + Get project name from repository root folder name. + #> + param([string]$RepoPath) + return Split-Path $RepoPath -Leaf +} + +function Test-ContainerRunning { + <# + .SYNOPSIS + Check if a devcontainer is running for a worktree path. + #> + param([string]$WorktreePath) + + $containerList = docker ps --filter "label=devcontainer.local_folder=$WorktreePath" --format "{{.ID}}" 2>$null + return ($containerList -and $containerList.Trim() -ne "") +} + +function Get-ContainerStatus { + <# + .SYNOPSIS + Get container status for a worktree by name. + #> + param([string]$WorktreeName) + + $allContainers = docker ps -a --format '{{.ID}}|{{.Label "git.worktree"}}|{{.Label "devcontainer.local_folder"}}|{{.Status}}' 2>$null + foreach ($line in $allContainers) { + if (-not $line) { continue } + + $parts = $line -split '\|' + $id = $parts[0] + $worktreeLabel = $parts[1] + $localFolder = $parts[2] + $status = $parts[3] + + if ($worktreeLabel -eq $WorktreeName) { + if ($status -match "^Up") { + return "๐ŸŸข Running" + } else { + return "๐Ÿ”ด Stopped" + } + } + + if ($localFolder) { + $labelName = Split-Path -Leaf $localFolder + if ($labelName -eq $WorktreeName) { + if ($status -match "^Up") { + return "๐ŸŸข Running" + } else { + return "๐Ÿ”ด Stopped" + } + } + } + } + + return "โšช No container" +} + +function Remove-DevContainer { + <# + .SYNOPSIS + Stop and remove devcontainer(s) for a worktree path. + #> + param([string]$WorktreePath) + + $folderName = Split-Path $WorktreePath -Leaf + + $allContainers = docker ps -a --format '{{.ID}}' 2>$null + $containers = @() + foreach ($id in $allContainers) { + if (-not $id) { continue } + + $worktreeLabel = docker inspect --format '{{index .Config.Labels "git.worktree"}}' $id 2>$null + if ($worktreeLabel -eq $folderName) { + $containerName = docker inspect --format '{{.Name}}' $id 2>$null + $containers += "$id`t$containerName" + continue + } + + $labelPath = docker inspect --format '{{index .Config.Labels "devcontainer.local_folder"}}' $id 2>$null + if ($labelPath) { + $labelFolderName = Split-Path $labelPath -Leaf + if ($labelFolderName -eq $folderName) { + $containerName = docker inspect --format '{{.Name}}' $id 2>$null + $containers += "$id`t$containerName" + } + } + } + + if ($containers) { + Write-Host "๐Ÿณ Found devcontainer(s) for $folderName" -ForegroundColor Cyan + foreach ($container in $containers) { + $parts = $container -split "\t" + $containerId = $parts[0] + $containerName = $parts[1] + + Write-Host " Stopping: $containerName" -ForegroundColor Gray + docker stop $containerId 2>$null | Out-Null + + Write-Host " Removing: $containerName" -ForegroundColor Gray + docker rm $containerId 2>$null | Out-Null + } + Write-Host "โœ… Devcontainer(s) removed" -ForegroundColor Green + } + else { + Write-Host "โ„น๏ธ No devcontainer found for $folderName" -ForegroundColor Gray + } +} + +function Invoke-WorktreeUpHook { + <# + .SYNOPSIS + Run project-specific setup hook inside container if it exists. + #> + param([string]$WorktreePath) + + $upHookScript = Join-Path $WorktreePath ".devcontainer" "worktree-up-hook.sh" + if (Test-Path $upHookScript) { + Write-Host "๐Ÿ”ง Running project setup hook..." -ForegroundColor Cyan + $hookOutput = & devcontainer exec --workspace-folder $WorktreePath bash .devcontainer/worktree-up-hook.sh 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Host "โœ… Project setup hook completed" -ForegroundColor Green + } + else { + Write-Host "โš ๏ธ Project setup hook failed" -ForegroundColor Yellow + if ($hookOutput) { + Write-Host "Hook output:" -ForegroundColor DarkYellow + Write-Host $hookOutput + } + } + } +} diff --git a/MultiCopilot/Public/Clear-CopilotWorktree.ps1 b/MultiCopilot/Public/Clear-CopilotWorktree.ps1 new file mode 100644 index 0000000..b9b734b --- /dev/null +++ b/MultiCopilot/Public/Clear-CopilotWorktree.ps1 @@ -0,0 +1,271 @@ +# Copyright 2026 James Casey +# SPDX-License-Identifier: Apache-2.0 + +function Clear-CopilotWorktree { + <# + .SYNOPSIS + Clean up orphaned devcontainers and volumes for this repository's worktrees. + + .DESCRIPTION + Finds and removes Docker containers and volumes that were created for worktrees + that no longer exist. Volumes in use are safely skipped. + + .PARAMETER All + Remove all containers for this project, even for active worktrees + + .PARAMETER DryRun + Show what would be removed without actually removing anything + + .PARAMETER NoConfirm + Skip confirmation prompts + + .EXAMPLE + Clear-CopilotWorktree + + .EXAMPLE + Clear-CopilotWorktree -All + + .EXAMPLE + Clear-CopilotWorktree -DryRun + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory=$false)] + [switch]$All, + + [Parameter(Mandatory=$false)] + [switch]$DryRun, + + [Parameter(Mandatory=$false)] + [switch]$NoConfirm + ) + + $ErrorActionPreference = "Stop" + + # Get project name from git repo + try { + $repoRoot = Get-MainRepoRoot + } + catch { + Write-Host "โŒ Not in a git repository" -ForegroundColor Red + return + } + $projectName = Split-Path -Leaf $repoRoot + + Write-Host "๐Ÿณ Container Cleanup for: $projectName" -ForegroundColor Cyan + Write-Host "" + + # Get list of active worktree paths + $activeWorktrees = @() + $worktreeParentDir = $null + if (-not $All) { + Write-Host "๐Ÿ“‚ Finding active worktrees..." -ForegroundColor Cyan + $worktrees = git worktree list --porcelain 2>$null + $currentPath = $null + foreach ($line in $worktrees) { + if ($line -match "^worktree\s+(.+)") { + $currentPath = $matches[1] + $activeWorktrees += $currentPath + Write-Host " โœ“ $currentPath" -ForegroundColor Gray + # Derive the parent directory + if (-not $worktreeParentDir) { + $worktreeParentDir = (Split-Path -Parent $currentPath) -replace "\\", "/" + } + } + } + Write-Host "" + } + + # Find all project-related containers + Write-Host "๐Ÿ” Scanning Docker containers..." -ForegroundColor Cyan + $allContainers = docker ps -a --format "{{.ID}}|{{.Names}}|{{.Image}}|{{.Status}}" 2>$null + + $projectContainers = @() + $orphanedContainers = @() + + foreach ($line in $allContainers) { + if (-not $line) { continue } + + $parts = $line -split "\|" + $id = $parts[0] + $name = $parts[1] + $image = $parts[2] + $status = $parts[3] + + # Check if this is a container for THIS project's worktrees + # Match by: image name containing project, git.worktree label, or local_folder in our worktree parent directory + $worktreeLabel = docker inspect --format '{{index .Config.Labels "git.worktree"}}' $id 2>$null + $localFolder = docker inspect --format '{{index .Config.Labels "devcontainer.local_folder"}}' $id 2>$null + $normalizedLocalFolder = if ($localFolder) { $localFolder -replace "\\", "/" } else { "" } + + # Only consider containers that belong to THIS project's worktree directory + # Must match BOTH a project identifier AND be in our worktree directory structure + $isThisProject = $false + + # Primary check: local_folder must be within this repo's directory structure + $repoRootNormalized = $repoRoot -replace "\\", "/" + $isInRepoDir = $normalizedLocalFolder -and $normalizedLocalFolder -match [regex]::Escape($repoRootNormalized) + + if ($isInRepoDir) { + # Container's local_folder is within this repo - definitely ours + $isThisProject = $true + } elseif (($image -match [regex]::Escape($projectName) -or $image -match "vsc-$projectName") -and -not $normalizedLocalFolder) { + # Image matches project name and no local_folder to check - assume ours + # (legacy containers without labels) + $isThisProject = $true + } + + if ($isThisProject) { + # Get worktree name from label or local_folder + $worktreeName = if ($worktreeLabel) { + $worktreeLabel + } elseif ($localFolder) { + Split-Path -Leaf $localFolder + } else { + "unknown" + } + + $container = @{ + Id = $id + Name = $name + Image = $image + Status = $status + WorktreeLabel = $worktreeLabel + WorktreeName = $worktreeName + } + $projectContainers += $container + + # Check if it's orphaned (if not running --all mode) + if (-not $All) { + $isOrphaned = $true + + # Check by our custom git.worktree label first (most reliable) + if ($worktreeLabel) { + foreach ($worktree in $activeWorktrees) { + $worktreeName = Split-Path -Leaf $worktree + if ($worktreeName -eq $worktreeLabel) { + $isOrphaned = $false + break + } + } + } + # Fallback to devcontainer.local_folder label for containers created before label was added + else { + $labelJson = docker inspect --format '{{index .Config.Labels "devcontainer.local_folder"}}' $id 2>$null + if ($labelJson) { + foreach ($worktree in $activeWorktrees) { + $worktreeName = Split-Path -Leaf $worktree + $labelName = Split-Path -Leaf $labelJson + $normalizedWorktree = $worktree -replace "\\", "/" + $normalizedLabel = $labelJson -replace "\\", "/" + + if ($worktreeName -eq $labelName -or $normalizedLabel -match [regex]::Escape($normalizedWorktree)) { + $isOrphaned = $false + break + } + } + } + } + + if ($isOrphaned) { + $orphanedContainers += $container + } + } + } + } + + # Determine which containers to remove + $containersToRemove = if ($All) { $projectContainers } else { $orphanedContainers } + + Write-Host "Found:" -ForegroundColor White + Write-Host " Total project containers: $($projectContainers.Count)" -ForegroundColor Gray + if (-not $All) { + Write-Host " Active worktree containers: $($projectContainers.Count - $orphanedContainers.Count)" -ForegroundColor Green + Write-Host " Orphaned containers: $($orphanedContainers.Count)" -ForegroundColor Yellow + } + Write-Host "" + + if ($containersToRemove.Count -eq 0) { + Write-Host "โœจ No containers to remove!" -ForegroundColor Green + return + } + + # Display what will be removed + Write-Host "Will remove the following containers:" -ForegroundColor Yellow + foreach ($container in $containersToRemove) { + $statusColor = if ($container.Status -match "Up") { "Red" } else { "Gray" } + Write-Host " [$($container.Status)]" -ForegroundColor $statusColor -NoNewline + Write-Host " $($container.Name)" -ForegroundColor White -NoNewline + Write-Host " ($($container.WorktreeName))" -ForegroundColor DarkGray + } + Write-Host "" + + if ($DryRun) { + Write-Host "๐Ÿ” DRY RUN - No changes made" -ForegroundColor Cyan + return + } + + # Confirm before removing (skip prompts during -WhatIf) + if (-not $NoConfirm -and -not $WhatIfPreference) { + $confirm = Read-Host "Remove these containers? (y/N)" + if ($confirm -ne 'y' -and $confirm -ne 'Y') { + Write-Host "โŒ Cancelled" -ForegroundColor Red + return + } + } + + # Remove containers + Write-Host "" + Write-Host "๐Ÿ—‘๏ธ Removing containers..." -ForegroundColor Cyan + $removed = 0 + foreach ($container in $containersToRemove) { + if ($PSCmdlet.ShouldProcess($container.Name, "Stop and remove container")) { + Write-Host " Stopping: $($container.Name)" -ForegroundColor Gray + docker stop $container.Id 2>$null | Out-Null + + Write-Host " Removing: $($container.Name)" -ForegroundColor Gray + docker rm $container.Id 2>$null | Out-Null + $removed++ + } + } + Write-Host "โœ… Removed $removed containers" -ForegroundColor Green + + # Find and remove orphaned volumes matching project name + Write-Host "" + Write-Host "๐Ÿ” Scanning for orphaned volumes..." -ForegroundColor Cyan + $allVolumes = docker volume ls --format "{{.Name}}" 2>$null + + $projectVolumes = @() + foreach ($volume in $allVolumes) { + # Devcontainer volumes typically include project name + if ($volume -match [regex]::Escape($projectName)) { + $projectVolumes += $volume + } + } + + if ($projectVolumes.Count -gt 0) { + Write-Host "Found $($projectVolumes.Count) project-related volumes" -ForegroundColor Gray + + $confirmVolumes = if ($NoConfirm -or $WhatIfPreference) { "y" } else { Read-Host "Remove unused volumes? (y/N)" } + if ($confirmVolumes -eq 'y' -or $confirmVolumes -eq 'Y') { + foreach ($volume in $projectVolumes) { + if ($PSCmdlet.ShouldProcess($volume, "Remove volume")) { + # Try to remove - will fail if still in use + $result = docker volume rm $volume 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Host " โœ“ Removed: $volume" -ForegroundColor Green + } + else { + Write-Host " โš ๏ธ Skipped (in use): $volume" -ForegroundColor Yellow + } + } + } + } + } + else { + Write-Host "No orphaned volumes found" -ForegroundColor Gray + } + + Write-Host "" + Write-Host "โœจ Done!" -ForegroundColor Green +} diff --git a/MultiCopilot/Public/Get-CopilotWorktree.ps1 b/MultiCopilot/Public/Get-CopilotWorktree.ps1 new file mode 100644 index 0000000..adca983 --- /dev/null +++ b/MultiCopilot/Public/Get-CopilotWorktree.ps1 @@ -0,0 +1,105 @@ +# Copyright 2026 James Casey +# SPDX-License-Identifier: Apache-2.0 + +function Get-CopilotWorktree { + <# + .SYNOPSIS + Show status of all git worktrees and their devcontainers. + + .DESCRIPTION + Lists all worktrees in the repository and shows which ones have + running or stopped devcontainers. + + .EXAMPLE + Get-CopilotWorktree + #> + [CmdletBinding()] + param() + + $ErrorActionPreference = "Stop" + + # Main logic + $mainRepo = Get-MainRepoRoot + $projectName = Split-Path $mainRepo -Leaf + + Write-Host "๐Ÿ“‚ Worktree Status for: $projectName" -ForegroundColor Cyan + Write-Host "" + + # Get all worktrees + Push-Location $mainRepo + try { + $worktrees = git worktree list --porcelain 2>$null + + $currentWorktree = $null + $currentBranch = $null + $results = @() + + foreach ($line in $worktrees) { + if ($line -match "^worktree\s+(.+)") { + # New worktree entry - save previous if exists + if ($currentWorktree) { + $worktreeName = Split-Path $currentWorktree -Leaf + $normalizedWorktree = $currentWorktree -replace '/', '\' + $normalizedMain = $mainRepo -replace '/', '\' + $isMain = $normalizedWorktree -eq $normalizedMain + + $results += @{ + Path = $currentWorktree + Name = $worktreeName + Branch = if ($currentBranch) { $currentBranch } else { "(detached)" } + IsMain = $isMain + Container = if ($isMain) { "-" } else { Get-ContainerStatus -WorktreeName $worktreeName } + } + } + $currentWorktree = $matches[1] + $currentBranch = $null + } + elseif ($line -match "^branch\s+refs/heads/(.+)") { + $currentBranch = $matches[1] + } + } + + # Don't forget the last entry + if ($currentWorktree) { + $worktreeName = Split-Path $currentWorktree -Leaf + $normalizedWorktree = $currentWorktree -replace '/', '\' + $normalizedMain = $mainRepo -replace '/', '\' + $isMain = $normalizedWorktree -eq $normalizedMain + + $results += @{ + Path = $currentWorktree + Name = $worktreeName + Branch = if ($currentBranch) { $currentBranch } else { "(detached)" } + IsMain = $isMain + Container = if ($isMain) { "-" } else { Get-ContainerStatus -WorktreeName $worktreeName } + } + } + + # Display results + if ($results.Count -eq 0) { + Write-Host "No worktrees found." -ForegroundColor Gray + } + else { + # Header + Write-Host ("{0,-20} {1,-20} {2}" -f "BRANCH", "WORKTREE", "CONTAINER") -ForegroundColor White + Write-Host ("{0,-20} {1,-20} {2}" -f "------", "--------", "---------") -ForegroundColor DarkGray + + foreach ($wt in $results) { + $branchDisplay = $wt.Branch + $nameDisplay = if ($wt.IsMain) { "(main repo)" } else { $wt.Name } + $containerDisplay = $wt.Container + + $branchColor = if ($wt.IsMain) { "DarkGray" } else { "Yellow" } + + Write-Host ("{0,-20}" -f $branchDisplay) -ForegroundColor $branchColor -NoNewline + Write-Host (" {0,-20}" -f $nameDisplay) -ForegroundColor Gray -NoNewline + Write-Host (" {0}" -f $containerDisplay) + } + } + + Write-Host "" + } + finally { + Pop-Location + } +} diff --git a/MultiCopilot/Public/Initialize-CopilotProject.ps1 b/MultiCopilot/Public/Initialize-CopilotProject.ps1 new file mode 100644 index 0000000..0782649 --- /dev/null +++ b/MultiCopilot/Public/Initialize-CopilotProject.ps1 @@ -0,0 +1,155 @@ +# Copyright 2026 James Casey +# SPDX-License-Identifier: Apache-2.0 + +function Initialize-CopilotProject { + <# + .SYNOPSIS + Scaffold devcontainer template files into a project directory. + + .DESCRIPTION + Copies the MultiCopilot .devcontainer/ template files and supporting + scripts into the target project directory. This replaces the manual + "copy these files" step from the old workflow. + + The target must be a git repository (must contain a .git directory). + If .devcontainer/ already exists use -Force to overwrite. + + .PARAMETER Path + Target project directory. Defaults to the current directory. + + .PARAMETER Force + Overwrite existing .devcontainer/ files if they already exist. + + .EXAMPLE + Initialize-CopilotProject + + Scaffold template files into the current directory. + + .EXAMPLE + Initialize-CopilotProject -Path C:\Projects\my-app + + Scaffold template files into the specified project directory. + + .EXAMPLE + Initialize-CopilotProject -Force + + Overwrite existing .devcontainer/ files in the current directory. + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Position = 0)] + [string]$Path = (Get-Location).Path, + + [switch]$Force + ) + + $ErrorActionPreference = "Stop" + + $targetPath = Resolve-Path $Path -ErrorAction Stop | Select-Object -ExpandProperty Path + $templateRoot = Join-Path $PSScriptRoot ".." "Templates" + $templateRoot = Resolve-Path $templateRoot | Select-Object -ExpandProperty Path + + # Validate target is a git repository + $gitDir = Join-Path $targetPath ".git" + if (-not (Test-Path $gitDir)) { + Write-Error "Target directory is not a git repository: $targetPath" + return + } + + # Check for existing .devcontainer/ + $devcontainerDir = Join-Path $targetPath ".devcontainer" + if ((Test-Path $devcontainerDir) -and -not $Force) { + Write-Warning ".devcontainer/ already exists in $targetPath. Use -Force to overwrite." + return + } + + $createdFiles = @() + + # Copy .devcontainer/ directory + $templateDevcontainer = Join-Path $templateRoot ".devcontainer" + if ($PSCmdlet.ShouldProcess($devcontainerDir, "Copy .devcontainer/ template files")) { + if (-not (Test-Path $devcontainerDir)) { + New-Item -ItemType Directory -Path $devcontainerDir -Force | Out-Null + } + foreach ($file in Get-ChildItem -Path $templateDevcontainer -File) { + $dest = Join-Path $devcontainerDir $file.Name + Copy-Item -Path $file.FullName -Destination $dest -Force + $createdFiles += ".devcontainer/$($file.Name)" + } + } + + # Copy smoke-test.sh to scripts/smoke-test.sh + $templateSmokeTest = Join-Path $templateRoot "smoke-test.sh" + $scriptsDir = Join-Path $targetPath "scripts" + $destSmokeTest = Join-Path $scriptsDir "smoke-test.sh" + if ($PSCmdlet.ShouldProcess($destSmokeTest, "Copy smoke-test.sh")) { + if (-not (Test-Path $scriptsDir)) { + New-Item -ItemType Directory -Path $scriptsDir -Force | Out-Null + } + Copy-Item -Path $templateSmokeTest -Destination $destSmokeTest -Force + $createdFiles += "scripts/smoke-test.sh" + } + + # Merge .gitattributes entries + $templateGitattributes = Join-Path $templateRoot ".gitattributes" + $destGitattributes = Join-Path $targetPath ".gitattributes" + if ($PSCmdlet.ShouldProcess($destGitattributes, "Merge .gitattributes entries")) { + $templateLines = Get-Content $templateGitattributes + if (Test-Path $destGitattributes) { + $existingContent = Get-Content $destGitattributes -Raw + $linesToAdd = @() + foreach ($line in $templateLines) { + $trimmed = $line.Trim() + if ($trimmed -eq "" -or $trimmed.StartsWith("#")) { continue } + if ($existingContent -notmatch [regex]::Escape($trimmed)) { + $linesToAdd += $line + } + } + if ($linesToAdd.Count -gt 0) { + $separator = "`n`n# Added by MultiCopilot`n" + $addition = $separator + ($linesToAdd -join "`n") + "`n" + Add-Content -Path $destGitattributes -Value $addition -NoNewline + $createdFiles += ".gitattributes (merged)" + } + else { + $createdFiles += ".gitattributes (no changes needed)" + } + } + else { + Copy-Item -Path $templateGitattributes -Destination $destGitattributes + $createdFiles += ".gitattributes" + } + } + + # Add .worktrees/ to .gitignore if not already present + $gitignorePath = Join-Path $targetPath ".gitignore" + if ($PSCmdlet.ShouldProcess($gitignorePath, "Add .worktrees/ to .gitignore")) { + $worktreeEntry = ".worktrees/" + $needsAdd = $true + if (Test-Path $gitignorePath) { + $gitignoreContent = Get-Content $gitignorePath -Raw + if ($gitignoreContent -match [regex]::Escape($worktreeEntry)) { + $needsAdd = $false + } + } + if ($needsAdd) { + if (Test-Path $gitignorePath) { + Add-Content -Path $gitignorePath -Value "`n# MultiCopilot worktrees`n$worktreeEntry`n" + } + else { + Set-Content -Path $gitignorePath -Value "# MultiCopilot worktrees`n$worktreeEntry`n" + } + $createdFiles += ".gitignore (added .worktrees/)" + } + } + + # Summary + Write-Host "" + Write-Host "โœ… Project initialized for MultiCopilot" -ForegroundColor Green + Write-Host "" + Write-Host "Files created/updated:" -ForegroundColor Cyan + foreach ($file in $createdFiles) { + Write-Host " $file" -ForegroundColor Gray + } + Write-Host "" +} diff --git a/MultiCopilot/Public/New-CopilotWorktree.ps1 b/MultiCopilot/Public/New-CopilotWorktree.ps1 new file mode 100644 index 0000000..026be64 --- /dev/null +++ b/MultiCopilot/Public/New-CopilotWorktree.ps1 @@ -0,0 +1,553 @@ +# Copyright 2026 James Casey +# SPDX-License-Identifier: Apache-2.0 + +function New-CopilotWorktree { + <# + .SYNOPSIS + Start a Copilot devcontainer session for a git worktree branch. + + .DESCRIPTION + Creates a worktree for the given branch (if needed) and starts + a devcontainer. Each worktree gets its own container for parallel + Copilot sessions. + + .PARAMETER Branch + The branch name to work on (e.g., "feature-branch" or "main") + + .PARAMETER BaseBranch + The base branch to create new branches from (default: auto-detect main/master) + + .PARAMETER WorktreeRoot + Directory where worktrees are created (default: .worktrees/ inside main repo) + + .PARAMETER Command + Command to run in container (default: "copilot --yolo") + + .PARAMETER Amp + Start Amp instead of Copilot (sets Command to "amp") + + .PARAMETER Rebuild + Force rebuild of the devcontainer image (no cache) + + .EXAMPLE + New-CopilotWorktree feature-branch + + .EXAMPLE + New-CopilotWorktree -Branch issue-123 -BaseBranch main + + .EXAMPLE + New-CopilotWorktree feature-branch -Command bash + + .EXAMPLE + New-CopilotWorktree feature-branch -Amp + + .EXAMPLE + New-CopilotWorktree feature-branch -Rebuild + #> + [CmdletBinding()] + param( + [Parameter(Mandatory, Position = 0)] + [string]$Branch, + + [Parameter()] + [string]$BaseBranch = "", + + [Parameter()] + [string]$WorktreeRoot = "", + + [Parameter()] + [string]$Command = "copilot --yolo", + + [Parameter()] + [switch]$Amp, + + [Parameter()] + [switch]$Rebuild + ) + + $ErrorActionPreference = "Stop" + $skipWorktreeSetup = $false + + if ($Amp -and $PSBoundParameters.ContainsKey('Command')) { + Write-Error "-Amp and -Command are mutually exclusive. Use one or the other." + return + } + + if ($Amp) { + $Command = "amp" + } + + # Check AMP_API_KEY early if using Amp + if ($Amp -and -not $env:AMP_API_KEY) { + Write-Host "โŒ AMP_API_KEY not set. Required for Amp." -ForegroundColor Red + Write-Host " Sign in at: ampcode.com/install" -ForegroundColor Gray + Write-Host " Then set: `$env:AMP_API_KEY = " -ForegroundColor Gray + return + } + + # Main script + Write-Host "๐Ÿš€ Starting worktree session: $Branch" -ForegroundColor Cyan + + # Find main repo + $mainRepo = Get-MainRepoRoot + $projectName = Get-ProjectName -RepoPath $mainRepo + Write-Host "๐Ÿ“ Main repo: $mainRepo" -ForegroundColor Gray + Write-Host "๐Ÿ“ Project: $projectName" -ForegroundColor Gray + + # Set worktree root if not specified (default: .worktrees/ inside repo) + if (-not $WorktreeRoot) { + $WorktreeRoot = Join-Path $mainRepo ".worktrees" + } + + # Sanitize name for directory (replace / with -) + $dirName = $Branch -replace '/', '-' + $worktreePath = Join-Path $WorktreeRoot $dirName + + # FAST PATH: If container is already running for this worktree, just reconnect + # This handles the case where you've switched branches inside the container + if (Test-Path $worktreePath) { + if (Test-ContainerRunning -WorktreePath $worktreePath) { + if ($Rebuild) { + Write-Host "โš ๏ธ Container already running โ€” cannot rebuild while running." -ForegroundColor Yellow + Write-Host " Run: Remove-CopilotWorktree $Branch" -ForegroundColor Gray + Write-Host " Then re-run with -Rebuild" -ForegroundColor Gray + return + } + + Write-Host "โœ… Container already running for worktree: $dirName" -ForegroundColor Green + + # Show what branch is actually checked out (informational only) + Push-Location $worktreePath + try { + $actualBranch = git branch --show-current 2>$null + if ($actualBranch -and $actualBranch -ne $Branch) { + Write-Host " Note: Currently on branch '$actualBranch' (worktree was created as '$Branch')" -ForegroundColor Gray + } + } + finally { + Pop-Location + } + + # Skip to container connection (jump past worktree setup) + $skipWorktreeSetup = $true + } + } + + if (-not $skipWorktreeSetup) { + # Ensure worktree root exists + if (-not (Test-Path $WorktreeRoot)) { + Write-Host "๐Ÿ“ Creating .worktrees directory..." -ForegroundColor Cyan + New-Item -ItemType Directory -Path $WorktreeRoot -Force | Out-Null + } + + # Set base branch if not specified + if (-not $BaseBranch) { + Push-Location $mainRepo + $BaseBranch = Get-DefaultBranch + Pop-Location + Write-Host "๐Ÿ“Œ Using default branch: $BaseBranch" -ForegroundColor Gray + } + + # Check if we're already on this branch in current directory + $currentBranch = git branch --show-current 2>$null + $currentDir = Get-Location + + # Always fetch latest from origin first + Write-Host "๐Ÿ”„ Fetching latest from origin..." -ForegroundColor Cyan + Push-Location $mainRepo + try { + git fetch origin --prune 2>$null + } + finally { + Pop-Location + } + + if ($currentBranch -eq $Branch) { + Write-Host "โœ… Already on branch $Branch in current directory" -ForegroundColor Green + $worktreePath = $currentDir.Path + } + else { + # Check if worktree already exists + Push-Location $mainRepo + try { + $existingWorktree = Find-WorktreeForBranch -BranchName $Branch -WorktreeRoot $WorktreeRoot + + if ($existingWorktree -and (Test-Path $existingWorktree)) { + # Worktree exists and is accessible from Windows + Write-Host "โœ… Worktree already exists at: $existingWorktree" -ForegroundColor Green + $worktreePath = $existingWorktree + } + elseif ($existingWorktree) { + # Worktree metadata exists but path is inaccessible (likely orphaned container path) + Write-Host "โš ๏ธ Worktree metadata exists but path inaccessible: $existingWorktree" -ForegroundColor Yellow + Write-Host "๐Ÿ”„ Removing orphaned worktree and recreating..." -ForegroundColor Cyan + git worktree remove $Branch --force 2>$null + + # Create new worktree at proper Windows path + $dirName = $Branch -replace '/', '-' + $worktreePath = Join-Path $WorktreeRoot $dirName + + # Remove directory if it exists (may be leftover from previous container) + if (Test-Path $worktreePath) { + Write-Host "๐Ÿ—‘๏ธ Removing existing directory: $worktreePath" -ForegroundColor Gray + try { + Remove-Item -Path $worktreePath -Recurse -Force -ErrorAction Stop + } + catch { + Write-Host "โŒ Cannot remove directory (may be in use by container)" -ForegroundColor Red + Write-Host " Run: Remove-CopilotWorktree $Branch" -ForegroundColor Gray + Write-Host " Or: docker stop " -ForegroundColor Gray + return + } + } + + Write-Host "๐Ÿ“Œ Recreating worktree for branch: $Branch" -ForegroundColor Yellow + git worktree add $worktreePath $Branch + + if ($LASTEXITCODE -ne 0) { + Write-Host "โŒ Failed to recreate worktree" -ForegroundColor Red + return + } + + Write-Host "โœ… Worktree recreated at: $worktreePath" -ForegroundColor Green + } + else { + # Create new worktree + # Sanitize branch name for directory (replace / with -) + $dirName = $Branch -replace '/', '-' + $worktreePath = Join-Path $WorktreeRoot $dirName + + if (Test-BranchExists -BranchName $Branch) { + Write-Host "๐Ÿ“Œ Creating worktree for existing branch: $Branch" -ForegroundColor Yellow + git worktree add $worktreePath $Branch + } + else { + Write-Host "๐ŸŒฑ Creating worktree with new branch: $Branch (from $BaseBranch)" -ForegroundColor Yellow + git worktree add -b $Branch $worktreePath $BaseBranch + } + + if ($LASTEXITCODE -ne 0) { + Write-Host "โŒ Failed to create worktree" -ForegroundColor Red + return + } + + Write-Host "โœ… Worktree created at: $worktreePath" -ForegroundColor Green + } + } + finally { + Pop-Location + } + } + } # End of: if (-not $skipWorktreeSetup) for worktree creation + + # Update worktree with latest changes from remote (skip if container already running) + if (-not $skipWorktreeSetup) { + if (Test-Path $worktreePath) { + Write-Host "๐Ÿ”„ Updating worktree with latest changes..." -ForegroundColor Cyan + Push-Location $worktreePath + try { + $trackingBranch = git rev-parse --abbrev-ref "@{upstream}" 2>$null + if ($trackingBranch) { + git pull --ff-only 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Host "โœ… Worktree updated" -ForegroundColor Green + } + else { + Write-Host "โš ๏ธ Could not fast-forward, may need manual merge" -ForegroundColor Yellow + } + } + else { + Write-Host "โ„น๏ธ No upstream tracking branch, skipping pull" -ForegroundColor Gray + } + } + finally { + Pop-Location + } + } + else { + Write-Host "โ„น๏ธ Worktree path not accessible from host, will update in container" -ForegroundColor Gray + } + } # End of: if (-not $skipWorktreeSetup) + + # Check for devcontainer CLI + $devcontainerCli = Get-Command devcontainer -ErrorAction SilentlyContinue + if (-not $devcontainerCli) { + Write-Host "โš ๏ธ devcontainer CLI not found. Installing..." -ForegroundColor Yellow + npm install -g @devcontainers/cli + } + + # Check GH_TOKEN is set (required for GitHub authentication in container) + if (-not $env:GH_TOKEN) { + Write-Host "โŒ GH_TOKEN not set. Required for GitHub authentication." -ForegroundColor Red + Write-Host " Authenticate with: gh auth login" -ForegroundColor Gray + Write-Host " Then set: `$env:GH_TOKEN = (gh auth token)" -ForegroundColor Gray + return + } + + # Set MAIN_GIT_PATH (auto-derived from main repo) + $env:MAIN_GIT_PATH = Join-Path $mainRepo ".git" + + # These variables are needed for both setup and reconnect paths + $worktreeName = Split-Path $worktreePath -Leaf + $worktreeGitFile = Join-Path $worktreePath ".git" + $worktreeMetaGitdir = Join-Path $env:MAIN_GIT_PATH "worktrees" $worktreeName "gitdir" + + # Skip container setup if already running - just reconnect + if ($skipWorktreeSetup) { + Write-Host "`n๐Ÿ”Œ Reconnecting to existing container..." -ForegroundColor Cyan + + # IMPORTANT: Pre-fix git paths for container before exec + # The finally block resets paths to host format after each run, + # so we must convert them back to container format before reconnecting + Write-Host "๐Ÿ”ง Pre-fixing .git paths for container..." -ForegroundColor Cyan + $containerGitPath = "/workspaces/.$projectName-git/worktrees/$worktreeName" + Set-Content -Path $worktreeGitFile -Value "gitdir: $containerGitPath" -NoNewline + Write-Host " Set .git to: $containerGitPath" -ForegroundColor Gray + + if (Test-Path $worktreeMetaGitdir) { + Set-Content -Path $worktreeMetaGitdir -Value "/workspaces/$worktreeName" -NoNewline + Write-Host " Set gitdir to: /workspaces/$worktreeName" -ForegroundColor Gray + } + Write-Host "โœ… Git paths pre-configured for container" -ForegroundColor Green + + Invoke-WorktreeUpHook -WorktreePath $worktreePath + } + else { + # Sync devcontainer config from main repo (worktrees may be created from old commits) + Write-Host "๐Ÿ“‹ Syncing devcontainer config..." -ForegroundColor Cyan + $mainDevcontainer = Join-Path $mainRepo ".devcontainer" + $worktreeDevcontainer = Join-Path $worktreePath ".devcontainer" + if (Test-Path $mainDevcontainer) { + Copy-Item -Path "$mainDevcontainer\*" -Destination $worktreeDevcontainer -Force -Recurse + Write-Host "โœ… Synced .devcontainer from main repo" -ForegroundColor Green + } + + # Pre-fix worktree .git file for container paths BEFORE starting container + # This prevents "fatal: not a git repository" errors during postStartCommand + Write-Host "๐Ÿ”ง Pre-fixing .git paths for container..." -ForegroundColor Cyan + # IMPORTANT: Point to the worktree-specific git directory + # The .git file contains "gitdir: " where is inside .git/worktrees/ + $containerGitPath = "/workspaces/.$projectName-git/worktrees/$worktreeName" + Set-Content -Path $worktreeGitFile -Value "gitdir: $containerGitPath" -NoNewline + Write-Host " Set .git to: $containerGitPath" -ForegroundColor Gray + + # Also fix the gitdir file in the main repo's worktree metadata + if (Test-Path $worktreeMetaGitdir) { + Set-Content -Path $worktreeMetaGitdir -Value "/workspaces/$worktreeName" -NoNewline + Write-Host " Set gitdir to: /workspaces/$worktreeName" -ForegroundColor Gray + } + Write-Host "โœ… Git paths pre-configured for container" -ForegroundColor Green + + # Start devcontainer + Write-Host "`n๐Ÿณ Starting devcontainer..." -ForegroundColor Cyan + Write-Host " Workspace: $worktreePath" -ForegroundColor Gray + + # Suppress Docker CLI hints (e.g., "Try Docker Debug...") + $env:DOCKER_CLI_HINTS = "false" + + # Build and start the container - capture output to get container ID + # Explicitly mount the main .git directory since ${localEnv:...} doesn't work reliably + $mountArg = "--mount=type=bind,source=$($env:MAIN_GIT_PATH),target=/workspaces/.$projectName-git" + $rebuildArgs = if ($Rebuild) { "--remove-existing-container" } else { "" } + $displayCmd = "devcontainer up --workspace-folder $worktreePath $mountArg $rebuildArgs".Trim() + Write-Host "Running: $displayCmd" -ForegroundColor Gray + + $upArgs = @("up", "--workspace-folder", $worktreePath, $mountArg) + if ($Rebuild) { + $upArgs += "--remove-existing-container" + Write-Host "๐Ÿ”จ Rebuilding devcontainer (removing existing container)..." -ForegroundColor Yellow + } + + $output = & devcontainer @upArgs 2>&1 | ForEach-Object { + # Filter out PowerShell's stderr wrapper noise and Docker hints + if ($_ -is [System.Management.Automation.ErrorRecord]) { + $line = $_.Exception.Message + } else { + $line = $_ + } + # Strip devcontainer CLI timestamp prefix (e.g., "[2026-02-06T13:41:03.568Z] ") + # so downstream regex filters can match the actual content + $stripped = $line -replace '^\[[\d\-T:.Z]+\]\s*', '' + # Suppress Docker build noise (layer steps, apt output, download progress, etc.) + # Keep devcontainer lifecycle output (postCreateCommand, postStartCommand, etc.) + if ($stripped -and + $stripped -notmatch '^System\.Management\.Automation\.RemoteException' -and + $stripped -notmatch "What's next:|Try Docker Debug|Learn more at https://docs\.docker\.com" -and + $stripped -notmatch '^\s*#\d+\s' -and # Docker BuildKit step lines (#1, #2 [internal], etc.) + $stripped -notmatch '^\s*--->' -and # Legacy Docker builder layer IDs + $stripped -notmatch '^(Step \d+/\d+|Removing intermediate|Successfully (built|tagged))' -and + $stripped -notmatch '^(Sending build context|COPY|RUN|FROM|ENV|WORKDIR|ARG|LABEL|EXPOSE|CMD|ENTRYPOINT|ADD|VOLUME|USER|SHELL|ONBUILD|STOPSIGNAL|HEALTHCHECK)' -and + $stripped -notmatch '^\s*(Get:|Hit:|Ign:|Fetched |Reading |Building )' -and # apt-get output + $stripped -notmatch '^\s*(\d+\.\d+ [kMG]B|Downloading|Unpacking|Setting up|Selecting|Preparing|Processing)' -and + $stripped -notmatch '^\s*(sha256:|digest:|resolve |resolved |DONE |CACHED )' -and + $stripped -notmatch '^\[[\d/ ]+\]') { # Docker progress like [1/5] + Write-Host $stripped + } + $line # Pass through original (with timestamp) to capture for JSON parsing + } + + if ($LASTEXITCODE -ne 0) { + Write-Host "`nโŒ Failed to start devcontainer" -ForegroundColor Red + return + } + + # Extract container ID from output JSON (last line) + $containerIdFromOutput = $null + try { + $lastLine = ($output | Select-Object -Last 1) -replace '\x1b\[[0-9;]*m', '' # Strip ANSI codes + $jsonOutput = $lastLine | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($jsonOutput.containerId) { + $containerIdFromOutput = $jsonOutput.containerId + } + } catch { + # Ignore JSON parse errors + } + + Write-Host "`nโœจ Container ready!" -ForegroundColor Green + + # Set up Copilot CLI config + Write-Host "๐Ÿค– Setting up Copilot CLI config..." -ForegroundColor Cyan + devcontainer exec --workspace-folder $worktreePath bash -c "mkdir -p ~/.copilot && envsubst < .devcontainer/mcp-config.json > ~/.copilot/mcp-config.json && envsubst < .devcontainer/copilot-config.json > ~/.copilot/config.json" 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Host "โœ… Copilot CLI configured" -ForegroundColor Green + } + else { + Write-Host "โš ๏ธ Could not configure Copilot CLI" -ForegroundColor Yellow + } + + # Set up Amp CLI config + Write-Host "โšก Setting up Amp CLI config..." -ForegroundColor Cyan + devcontainer exec --workspace-folder $worktreePath bash -c "mkdir -p ~/.config/amp && envsubst < .devcontainer/amp-settings.json > ~/.config/amp/settings.json" 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Host "โœ… Amp CLI configured" -ForegroundColor Green + } + else { + Write-Host "โš ๏ธ Could not configure Amp CLI" -ForegroundColor Yellow + } + + Invoke-WorktreeUpHook -WorktreePath $worktreePath + + # Copy SSH signing key if configured + if ($env:GIT_SIGNING_KEY) { + # For signing, we need the private key (not .pub) + # If user specified id_rsa.pub, use id_rsa instead + $keyName = $env:GIT_SIGNING_KEY -replace '\.pub$', '' + $privateKeyPath = Join-Path $env:USERPROFILE ".ssh\$keyName" + $publicKeyPath = Join-Path $env:USERPROFILE ".ssh\$keyName.pub" + + if ((Test-Path $privateKeyPath) -and (Test-Path $publicKeyPath)) { + Write-Host "๐Ÿ”‘ Copying SSH signing keys..." -ForegroundColor Cyan + + # Use container ID from devcontainer up output + $containerInfo = $containerIdFromOutput + + if ($containerInfo) { + Write-Host " Container ID: $containerInfo" -ForegroundColor Gray + + # Ensure .ssh directory exists in container with correct ownership + docker exec $containerInfo mkdir -p /home/vscode/.ssh 2>$null + docker exec $containerInfo chown -R vscode:vscode /home/vscode/.ssh 2>$null + docker exec $containerInfo chmod 700 /home/vscode/.ssh 2>$null + + # Copy private key + Write-Host " Copying private key: $privateKeyPath" -ForegroundColor Gray + $privateKeyContent = Get-Content $privateKeyPath -Raw + $privateKeyContent | docker exec -i $containerInfo tee /home/vscode/.ssh/$keyName 2>&1 | Out-Null + + if ($LASTEXITCODE -eq 0) { + # Set correct permissions for private key (600) + docker exec $containerInfo chown vscode:vscode /home/vscode/.ssh/$keyName 2>$null + docker exec $containerInfo chmod 600 /home/vscode/.ssh/$keyName 2>$null + + # Copy public key + Write-Host " Copying public key: $publicKeyPath" -ForegroundColor Gray + $publicKeyContent = Get-Content $publicKeyPath -Raw + $publicKeyContent | docker exec -i $containerInfo tee /home/vscode/.ssh/$keyName.pub 2>&1 | Out-Null + + # Set correct permissions for public key (644) + docker exec $containerInfo chown vscode:vscode /home/vscode/.ssh/$keyName.pub 2>$null + docker exec $containerInfo chmod 644 /home/vscode/.ssh/$keyName.pub 2>$null + + Write-Host "โœ… SSH keys copied, configuring git signing..." -ForegroundColor Green + + # Configure git to use SSH signing with the private key + devcontainer exec --workspace-folder $worktreePath git config --global gpg.format ssh + devcontainer exec --workspace-folder $worktreePath git config --global user.signingkey /home/vscode/.ssh/$keyName + devcontainer exec --workspace-folder $worktreePath git config --global commit.gpgsign true + devcontainer exec --workspace-folder $worktreePath git config --global tag.gpgsign true + + Write-Host "โœ… Git signing configured" -ForegroundColor Green + } + else { + Write-Host "โš ๏ธ Could not copy SSH keys to container" -ForegroundColor Yellow + } + } + else { + Write-Host "โš ๏ธ Could not find running container" -ForegroundColor Yellow + } + } + else { + Write-Host "โš ๏ธ SSH keys not found. Need both:" -ForegroundColor Yellow + Write-Host " Private: $privateKeyPath" -ForegroundColor Yellow + Write-Host " Public: $publicKeyPath" -ForegroundColor Yellow + } + } + } # End of: if (-not $skipWorktreeSetup) + + Write-Host "`nStarting: $Command" -ForegroundColor Cyan + + # Connect to the container and run the command + # Use try/finally to ensure git paths are reset when command exits + try { + $cmdArgs = @("exec", "--workspace-folder", $worktreePath) + ($Command -split '\s+') + & devcontainer @cmdArgs + } + finally { + # Reset git paths back to host paths + Write-Host "`n๐Ÿ”ง Resetting .git paths for host..." -ForegroundColor Cyan + + # Reset worktree .git file to point to host path + # (retry a few times in case file is briefly locked by container shutdown) + $hostGitPath = Join-Path $env:MAIN_GIT_PATH "worktrees" $worktreeName + $retries = 5 + $retryDelay = 1 + $success = $false + + for ($i = 0; $i -lt $retries; $i++) { + try { + Set-Content -Path $worktreeGitFile -Value "gitdir: $hostGitPath" -NoNewline -Encoding utf8NoBOM -ErrorAction Stop + $success = $true + break + } + catch { + if ($i -lt ($retries - 1)) { + Write-Host " Waiting for file lock to release... (attempt $($i+1)/$retries)" -ForegroundColor Gray + Start-Sleep -Seconds $retryDelay + } + } + } + + if (-not $success) { + Write-Host "โš ๏ธ Could not reset .git file (still locked). You may need to:" -ForegroundColor Yellow + Write-Host " 1. Wait a moment for container to fully stop" -ForegroundColor Gray + Write-Host " 2. Manually edit: $worktreeGitFile" -ForegroundColor Gray + Write-Host " 3. Set content to: gitdir: $hostGitPath" -ForegroundColor Gray + } + else { + Write-Host " Set .git to: $hostGitPath" -ForegroundColor Gray + + # Reset gitdir in main repo's worktree metadata to host path + if (Test-Path $worktreeMetaGitdir) { + try { + Set-Content -Path $worktreeMetaGitdir -Value $worktreePath -NoNewline -Encoding utf8NoBOM -ErrorAction Stop + Write-Host " Set gitdir to: $worktreePath" -ForegroundColor Gray + } + catch { + Write-Host "โš ๏ธ Could not reset gitdir file (still locked)" -ForegroundColor Yellow + } + } + + Write-Host "โœ… Git paths restored for host" -ForegroundColor Green + } + } +} diff --git a/MultiCopilot/Public/Remove-CopilotWorktree.ps1 b/MultiCopilot/Public/Remove-CopilotWorktree.ps1 new file mode 100644 index 0000000..5b74a3e --- /dev/null +++ b/MultiCopilot/Public/Remove-CopilotWorktree.ps1 @@ -0,0 +1,222 @@ +# Copyright 2026 James Casey +# SPDX-License-Identifier: Apache-2.0 + +function Remove-CopilotWorktree { + <# + .SYNOPSIS + Stop and remove a git worktree and its devcontainer. + + .DESCRIPTION + Removes a worktree that may have been used with devcontainers. + + This cmdlet: + - Stops and removes the devcontainer for the worktree + - Fixes any container path issues in .git file + - Removes the worktree directory + - Optionally deletes the branch + + Note: Path normalization is applied to handle git's forward slashes + vs Docker's backslashes on Windows. + + .PARAMETER Branch + The branch name of the worktree to remove (e.g., "feature-branch") + + .PARAMETER Force + Force removal even if there are uncommitted changes + + .EXAMPLE + Remove-CopilotWorktree feature-branch + + .EXAMPLE + Remove-CopilotWorktree -Branch issue-123 -Force + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory, Position = 0)] + [string]$Branch, + + [switch]$Force + ) + + $ErrorActionPreference = "Stop" + + Write-Host "๐Ÿ›‘ Stopping worktree for branch: $Branch" -ForegroundColor Cyan + + # Find main repo + $mainRepo = Get-MainRepoRoot + Write-Host "๐Ÿ“ Main repo: $mainRepo" -ForegroundColor Gray + + # Worktrees are in .worktrees/ subdirectory + $worktreeRoot = Join-Path $mainRepo ".worktrees" + + # Work from main repo + Push-Location $mainRepo + try { + # Find the worktree path + $worktreePath = Find-WorktreeForBranch -BranchName $Branch -WorktreeRoot $worktreeRoot + + if (-not $worktreePath) { + Write-Host "โš ๏ธ No worktree found for branch: $Branch" -ForegroundColor Yellow + Write-Host " Checking for orphaned worktree metadata..." -ForegroundColor Gray + + # Check if there's orphaned metadata + $worktreeMetaPath = Join-Path ".git" "worktrees" $Branch + if (Test-Path $worktreeMetaPath) { + Write-Host "๐Ÿงน Found orphaned metadata, cleaning up..." -ForegroundColor Yellow + if ($PSCmdlet.ShouldProcess($worktreeMetaPath, "Remove orphaned worktree metadata")) { + Remove-Item -Recurse -Force $worktreeMetaPath + Write-Host "โœ… Cleaned up orphaned worktree metadata" -ForegroundColor Green + } + } + else { + Write-Host "โŒ No worktree or metadata found for: $Branch" -ForegroundColor Red + } + return + } + + Write-Host "๐Ÿ“‚ Worktree path: $worktreePath" -ForegroundColor Gray + + # Show what branch is actually checked out (informational) + Push-Location $worktreePath + try { + $actualBranch = git branch --show-current 2>$null + if ($actualBranch -and $actualBranch -ne $Branch) { + Write-Host " Note: Currently on branch '$actualBranch' (worktree was created as '$Branch')" -ForegroundColor Gray + } + } + finally { + Pop-Location + } + + # Stop devcontainer FIRST (before removing worktree to release file locks) + if ($PSCmdlet.ShouldProcess($worktreePath, "Remove devcontainer")) { + Remove-DevContainer -WorktreePath $worktreePath + } + + # Run project-specific hook if it exists (for custom cleanup like volumes) + $hookScript = Join-Path $worktreePath ".devcontainer\worktree-down-hook.ps1" + if (Test-Path $hookScript) { + Write-Host "๐Ÿ”ง Running project cleanup hook..." -ForegroundColor Cyan + try { + & $hookScript -WorktreePath $worktreePath -Branch $Branch -MainRepo $mainRepo + if ($LASTEXITCODE -eq 0) { + Write-Host "โœ… Project cleanup hook completed" -ForegroundColor Green + } + else { + Write-Host "โš ๏ธ Project cleanup hook returned non-zero exit code" -ForegroundColor Yellow + } + } + catch { + Write-Host "โš ๏ธ Project cleanup hook failed: $_" -ForegroundColor Yellow + } + } + + # Check if the .git file in the worktree needs fixing + $gitFile = Join-Path $worktreePath ".git" + if (Test-Path $gitFile -PathType Leaf) { + $gitContent = Get-Content $gitFile -Raw + + # Check if it points to container path + if ($gitContent -match "/workspaces/") { + Write-Host "๐Ÿ”ง Fixing container path in .git file..." -ForegroundColor Yellow + + # Find the worktree name from the path + if ($gitContent -match "worktrees/([^/\r\n]+)") { + $worktreeName = $matches[1] + $correctPath = Join-Path $mainRepo ".git" "worktrees" $worktreeName + $correctPath = $correctPath -replace "\\", "/" + + Set-Content -Path $gitFile -Value "gitdir: $correctPath" -NoNewline + Write-Host "โœ… Fixed .git to point to: $correctPath" -ForegroundColor Green + } + } + } + elseif (-not (Test-Path $gitFile)) { + Write-Host "โš ๏ธ No .git file found in worktree (may be corrupted)" -ForegroundColor Yellow + } + + # Try standard worktree remove first + if ($PSCmdlet.ShouldProcess($worktreePath, "Remove git worktree")) { + $removeArgs = @("worktree", "remove", $worktreePath) + if ($Force) { + $removeArgs += "--force" + } + + Write-Host "๐Ÿ—‘๏ธ Removing worktree..." -ForegroundColor Cyan + $result = & git @removeArgs 2>&1 + + if ($LASTEXITCODE -eq 0) { + Write-Host "โœ… Worktree removed successfully" -ForegroundColor Green + } + elseif ($result -match "modified or untracked files") { + Write-Host "โŒ Worktree has uncommitted changes" -ForegroundColor Red + Write-Host " Use -Force to delete anyway, or commit/stash your changes first." -ForegroundColor Yellow + return + } + else { + Write-Host "โš ๏ธ git worktree remove failed: $result" -ForegroundColor Yellow + Write-Host "๐Ÿ”ง Attempting manual cleanup..." -ForegroundColor Cyan + + # Manual cleanup + $worktreeMetaPath = Join-Path ".git" "worktrees" $Branch + + # Also try with the worktree directory name if different from branch + $worktreeDirName = Split-Path $worktreePath -Leaf + $worktreeMetaPathAlt = Join-Path ".git" "worktrees" $worktreeDirName + + # Remove metadata + foreach ($metaPath in @($worktreeMetaPath, $worktreeMetaPathAlt)) { + if (Test-Path $metaPath) { + Write-Host " Removing: $metaPath" -ForegroundColor Gray + Remove-Item -Recurse -Force $metaPath + } + } + + # Remove worktree directory + if (Test-Path $worktreePath) { + Write-Host " Removing: $worktreePath" -ForegroundColor Gray + Remove-Item -Recurse -Force $worktreePath + } + + # Prune stale entries + git worktree prune + + Write-Host "โœ… Manual cleanup complete" -ForegroundColor Green + } + } + + # Optionally delete the branch (skip prompts during -WhatIf) + if (-not $WhatIfPreference) { + Write-Host "" + $deleteBranch = Read-Host "Delete local branch '$Branch'? (y/N)" + if ($deleteBranch -eq 'y' -or $deleteBranch -eq 'Y') { + if ($PSCmdlet.ShouldProcess($Branch, "Delete local branch")) { + # Update main branch to check against latest remote state + Write-Host "๐Ÿ”„ Updating main branch..." -ForegroundColor Cyan + git fetch origin main:main 2>$null + if ($LASTEXITCODE -ne 0) { + # Fallback if fast-forward fails + git fetch origin main 2>$null + } + + git branch -d $Branch 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Host "โš ๏ธ Branch not fully merged. Force delete? (y/N)" -ForegroundColor Yellow + $forceDelete = Read-Host + if ($forceDelete -eq 'y' -or $forceDelete -eq 'Y') { + git branch -D $Branch + } + } + if ($LASTEXITCODE -eq 0) { + Write-Host "โœ… Branch deleted" -ForegroundColor Green + } + } + } + } + } + finally { + Pop-Location + } + + Write-Host "`nโœจ Done!" -ForegroundColor Green +} diff --git a/.devcontainer/Dockerfile b/MultiCopilot/Templates/.devcontainer/Dockerfile similarity index 100% rename from .devcontainer/Dockerfile rename to MultiCopilot/Templates/.devcontainer/Dockerfile diff --git a/.devcontainer/README-copilot-config.md b/MultiCopilot/Templates/.devcontainer/README-copilot-config.md similarity index 100% rename from .devcontainer/README-copilot-config.md rename to MultiCopilot/Templates/.devcontainer/README-copilot-config.md diff --git a/.devcontainer/copilot-config.json b/MultiCopilot/Templates/.devcontainer/copilot-config.json similarity index 100% rename from .devcontainer/copilot-config.json rename to MultiCopilot/Templates/.devcontainer/copilot-config.json diff --git a/.devcontainer/devcontainer.json b/MultiCopilot/Templates/.devcontainer/devcontainer.json similarity index 100% rename from .devcontainer/devcontainer.json rename to MultiCopilot/Templates/.devcontainer/devcontainer.json diff --git a/.devcontainer/mcp-config.json b/MultiCopilot/Templates/.devcontainer/mcp-config.json similarity index 100% rename from .devcontainer/mcp-config.json rename to MultiCopilot/Templates/.devcontainer/mcp-config.json diff --git a/.devcontainer/setup-git-auth.sh b/MultiCopilot/Templates/.devcontainer/setup-git-auth.sh similarity index 100% rename from .devcontainer/setup-git-auth.sh rename to MultiCopilot/Templates/.devcontainer/setup-git-auth.sh diff --git a/MultiCopilot/Templates/.gitattributes b/MultiCopilot/Templates/.gitattributes new file mode 100644 index 0000000..b039bb0 --- /dev/null +++ b/MultiCopilot/Templates/.gitattributes @@ -0,0 +1,13 @@ +# Auto-detect text files and normalize line endings +* text=auto + +# Shell scripts must use LF (Unix) line endings +*.sh text eol=lf + +# PowerShell can use either, but prefer CRLF on Windows +*.ps1 text eol=crlf + +# Keep these as-is +*.png binary +*.jpg binary +*.gif binary diff --git a/scripts/smoke-test.sh b/MultiCopilot/Templates/smoke-test.sh similarity index 100% rename from scripts/smoke-test.sh rename to MultiCopilot/Templates/smoke-test.sh diff --git a/README.md b/README.md index efb8c26..8742a07 100644 --- a/README.md +++ b/README.md @@ -6,22 +6,45 @@ Inspired by [Jesse Vincent's workflow](https://blog.fsck.com/2025/10/05/how-im-u ## Overview -This repository provides reusable infrastructure (Windows host + PowerShell) that enables: +This repository provides a **PowerShell module** (Windows host) that enables: - **Parallel development**: Work on multiple branches simultaneously, each in its own container - **Isolated environments**: Each worktree gets its own devcontainer with isolated state -- **Easy setup**: PowerShell scripts automate worktree creation and container management -- **Project-agnostic**: Copy these files to any project to enable multi-copilot workflows +- **Easy setup**: Cmdlets automate worktree creation and container management +- **Project-agnostic**: Scaffold any project with `Initialize-CopilotProject` ## Quick Start -### 1. Copy to Your Project +### 1. Install the Module -Copy the following to your project: -- `.devcontainer/` - Devcontainer configuration -- `scripts/` - Worktree management scripts -- `.gitattributes` - Ensures shell scripts have correct line endings +**Option A: Clone and import** -### 2. Customize the Devcontainer +```powershell +git clone https://github.com/jamesc/multi-copilot.git +Import-Module ./multi-copilot/MultiCopilot +``` + +**Option B: Copy to your modules path** + +```powershell +git clone https://github.com/jamesc/multi-copilot.git +Copy-Item -Recurse multi-copilot/MultiCopilot "$($env:PSModulePath -split ';' | Select-Object -First 1)/MultiCopilot" +Import-Module MultiCopilot +``` + +### 2. Scaffold Your Project + +```powershell +cd C:\Projects\my-app +Initialize-CopilotProject +``` + +This creates: +- `.devcontainer/` โ€” Container configuration (Dockerfile, devcontainer.json, etc.) +- `scripts/smoke-test.sh` โ€” Devcontainer verification script +- `.gitattributes` โ€” Line ending configuration +- `.worktrees/` entry in `.gitignore` + +### 3. Customize the Devcontainer Edit `.devcontainer/Dockerfile` to add your project's dependencies (languages, tools, etc.). @@ -29,7 +52,7 @@ Edit `.devcontainer/devcontainer.json` to: - Change the `name` to your project name - Update VS Code extensions for your tech stack -### 3. Set Environment Variables +### 4. Set Environment Variables Set these on your Windows host: @@ -45,46 +68,84 @@ $env:GIT_USER_EMAIL = "your.email@example.com" $env:GIT_SIGNING_KEY = "id_ed25519" # or your key name ``` -### 4. Start a Worktree Session - -**Option A: PowerShell scripts (for parallel Copilot CLI sessions)** +### 5. Start a Worktree Session ```powershell -.\scripts\worktree-up.ps1 feature-branch +New-CopilotWorktree feature-branch ``` -**Option B: VS Code (for single-session development)** - -Open the folder in VS Code with the Dev Containers extension installed. VS Code will prompt to reopen in container. - This will: 1. Create a git worktree at `.worktrees/feature-branch/` 2. Start a devcontainer for that worktree 3. Configure Copilot CLI 4. Launch Copilot in the container -### 5. Verify Setup - -Inside the container, run: - -```bash -bash scripts/smoke-test.sh -``` - ### 6. Check Status ```powershell -.\scripts\worktree-status.ps1 +Get-CopilotWorktree ``` ### 7. Clean Up ```powershell # Remove a specific worktree and its container -.\scripts\worktree-down.ps1 feature-branch +Remove-CopilotWorktree feature-branch # Clean up orphaned containers and project volumes (prompts first) -.\scripts\worktree-cleanup.ps1 +Clear-CopilotWorktree +``` + +## Cmdlets + +| Cmdlet | Description | +|--------|-------------| +| `Initialize-CopilotProject` | Scaffold `.devcontainer/` template into a project | +| `New-CopilotWorktree` | Create a worktree and start a devcontainer | +| `Remove-CopilotWorktree` | Remove a worktree and clean up containers | +| `Get-CopilotWorktree` | Show status of all worktrees and containers | +| `Clear-CopilotWorktree` | Remove orphaned containers and project volumes | + +All cmdlets support `Get-Help`: + +```powershell +Get-Help New-CopilotWorktree -Full +``` + +### New-CopilotWorktree + +```powershell +# Basic usage +New-CopilotWorktree feature-branch + +# Create from a specific base branch +New-CopilotWorktree -Branch issue-123 -BaseBranch main + +# Run bash instead of copilot +New-CopilotWorktree feature-branch -Command bash + +# Start Amp instead of Copilot +New-CopilotWorktree feature-branch -Amp + +# Force rebuild of the devcontainer +New-CopilotWorktree feature-branch -Rebuild +``` + +### Remove-CopilotWorktree + +```powershell +Remove-CopilotWorktree feature-branch +Remove-CopilotWorktree -Branch issue-123 -Force +Remove-CopilotWorktree feature-branch -WhatIf # preview what would happen +``` + +### Clear-CopilotWorktree + +```powershell +Clear-CopilotWorktree # Interactive โ€” orphaned containers only +Clear-CopilotWorktree -DryRun # Preview only +Clear-CopilotWorktree -All # Remove ALL project containers +Clear-CopilotWorktree -WhatIf # PowerShell standard preview ``` ## How It Works @@ -95,7 +156,7 @@ Git worktrees allow multiple working directories linked to the same repository. Worktrees are created in `.worktrees/` inside your repo (gitignored). -**Branch Switching**: You can switch branches inside a worktree with `git checkout`. The scripts identify worktrees by directory name (not current branch), so `worktree-up` and `worktree-down` work correctly even after switching branches. +**Branch Switching**: You can switch branches inside a worktree with `git checkout`. The cmdlets identify worktrees by directory name (not current branch), so `New-CopilotWorktree` and `Remove-CopilotWorktree` work correctly even after switching branches. ### Devcontainers @@ -106,35 +167,41 @@ Each worktree gets its own Docker container with: ### Path Translation -The tricky part is that git worktrees on Windows use host paths, but inside containers we need container paths. The scripts handle this by: +The tricky part is that git worktrees on Windows use host paths, but inside containers we need container paths. The cmdlets handle this by: 1. Mounting the main `.git` directory at a known location 2. Rewriting worktree `.git` files to use container paths 3. Restoring host paths when exiting -## File Structure +## Module Structure ``` -.devcontainer/ -โ”œโ”€โ”€ devcontainer.json # Container configuration -โ”œโ”€โ”€ Dockerfile # Build dependencies (customize this) -โ”œโ”€โ”€ copilot-config.json # Copilot CLI settings (yolo mode) -โ”œโ”€โ”€ mcp-config.json # MCP server configuration -โ””โ”€โ”€ setup-git-auth.sh # Configure git authentication - -scripts/ -โ”œโ”€โ”€ worktree-up.ps1 # Create worktree + start container -โ”œโ”€โ”€ worktree-down.ps1 # Remove worktree + stop container -โ”œโ”€โ”€ worktree-status.ps1 # Show all worktrees and container status -โ”œโ”€โ”€ worktree-cleanup.ps1 # Clean up orphaned containers -โ”œโ”€โ”€ smoke-test.sh # Verify devcontainer setup -โ””โ”€โ”€ README.md # Script documentation +MultiCopilot/ +โ”œโ”€โ”€ MultiCopilot.psd1 # Module manifest +โ”œโ”€โ”€ MultiCopilot.psm1 # Module loader +โ”œโ”€โ”€ Public/ # Exported cmdlets +โ”‚ โ”œโ”€โ”€ Initialize-CopilotProject.ps1 +โ”‚ โ”œโ”€โ”€ New-CopilotWorktree.ps1 +โ”‚ โ”œโ”€โ”€ Remove-CopilotWorktree.ps1 +โ”‚ โ”œโ”€โ”€ Get-CopilotWorktree.ps1 +โ”‚ โ””โ”€โ”€ Clear-CopilotWorktree.ps1 +โ”œโ”€โ”€ Private/ # Internal helpers +โ”‚ โ””โ”€โ”€ GitHelpers.ps1 +โ””โ”€โ”€ Templates/ # Project scaffold files + โ”œโ”€โ”€ .devcontainer/ + โ”‚ โ”œโ”€โ”€ devcontainer.json + โ”‚ โ”œโ”€โ”€ Dockerfile + โ”‚ โ”œโ”€โ”€ copilot-config.json + โ”‚ โ”œโ”€โ”€ mcp-config.json + โ”‚ โ””โ”€โ”€ setup-git-auth.sh + โ”œโ”€โ”€ smoke-test.sh + โ””โ”€โ”€ .gitattributes ``` ## Customization ### Adding MCP Servers -Edit `.devcontainer/mcp-config.json`: +After scaffolding, edit `.devcontainer/mcp-config.json` in your project: ```json { @@ -163,9 +230,13 @@ Add to `remoteEnv` in `devcontainer.json`: } ``` +### Project-Specific Worktree Hook + +Create `.devcontainer/worktree-up-hook.sh` to run custom setup each time a worktree container starts. + ## Requirements -- Windows with PowerShell 5.1+ (scripts are PowerShell/Windows-focused) +- Windows with PowerShell 5.1+ - Git with worktree support - Docker Desktop - Node.js (for devcontainer CLI) diff --git a/scripts/README.md b/scripts/README.md deleted file mode 100644 index 8b44275..0000000 --- a/scripts/README.md +++ /dev/null @@ -1,173 +0,0 @@ -# Scripts - -Helper scripts for git worktree + devcontainer workflows. - -Worktrees are created in `.worktrees/` subdirectory inside the repo (inspired by [Jesse Vincent's workflow](https://blog.fsck.com/2025/10/05/how-im-using-coding-agents-in-september-2025/)). - -## Worktree Scripts - -| Script | Description | -|--------|-------------| -| `worktree-up.ps1` | Create a worktree and start a devcontainer | -| `worktree-down.ps1` | Remove a worktree and clean up containers | -| `worktree-status.ps1` | Show status of all worktrees and containers | -| `worktree-cleanup.ps1` | Remove orphaned containers and project volumes | -| `smoke-test.sh` | Verify devcontainer is working (run inside container) | - ---- - -## `worktree-up.ps1` - -Start a Copilot devcontainer session for a git worktree branch. This enables running multiple parallel Copilot sessions, each in its own container working on a different branch. - -### Usage - -```powershell -.\scripts\worktree-up.ps1 feature-branch - -# Create new branch from main -.\scripts\worktree-up.ps1 -Branch issue-123 -BaseBranch main - -# Run bash instead of copilot -.\scripts\worktree-up.ps1 feature-branch -Command bash - -# Start Amp instead of Copilot -.\scripts\worktree-up.ps1 feature-branch -Amp - -# Force rebuild of the devcontainer -.\scripts\worktree-up.ps1 feature-branch -Rebuild -``` - -### What it does - -1. **Checks if worktree exists** for the branch -2. **Creates worktree** if needed (new branch from base, or existing branch) -3. **Starts the devcontainer** using `devcontainer up` -4. **Configures Copilot CLI** with config and MCP servers -5. **Launches Copilot** (or specified command) in the container - -You'll get a shell inside the container where you can run Copilot CLI or other tools. - -### Prerequisites - -- Git with worktree support -- VS Code with Dev Containers extension -- `devcontainer` CLI (auto-installed if missing): `npm install -g @devcontainers/cli` -- `GH_TOKEN` environment variable set - ---- - -## `worktree-down.ps1` - -Stop and remove a git worktree, handling container path fixups automatically. - -### Usage - -```powershell -.\scripts\worktree-down.ps1 feature-branch - -# Force remove even with uncommitted changes -.\scripts\worktree-down.ps1 -Branch issue-123 -Force -``` - -### What it does - -1. **Finds the worktree** by directory name (not current branch) -2. **Stops and removes the devcontainer** -3. **Fixes the .git file** if it was modified for container paths -4. **Removes the worktree** using `git worktree remove` -5. **Falls back to manual cleanup** if standard removal fails -6. **Optionally deletes the branch** (prompts you) - -The script identifies worktrees by their **directory name** (based on the original branch name), so it works even if you've switched branches inside the worktree. - -### Why this is needed - -When a worktree is used inside a devcontainer, the `.git` file gets modified to point to container paths (`/workspaces/...`). This breaks `git worktree remove` on the host system. The script fixes the path before removal. - ---- - -## `worktree-cleanup.ps1` - -Remove containers and project volumes from worktrees that were deleted without using `worktree-down.ps1`. - -```powershell -.\scripts\worktree-cleanup.ps1 # Interactive -.\scripts\worktree-cleanup.ps1 -DryRun # Preview only -.\scripts\worktree-cleanup.ps1 -NoConfirm # Auto-confirm -.\scripts\worktree-cleanup.ps1 -All # Remove ALL project containers -``` - -Shows orphaned containers with their worktree names and lets you confirm before removal. Volumes are matched by project name and skipped if in use. - ---- - -## Environment Variables - -Set these on your Windows host before using the scripts: - -| Variable | Required | Description | -|----------|----------|-------------| -| `GH_TOKEN` | Yes | GitHub auth token (`gh auth token`) | -| `GIT_USER_NAME` | No | Git commit author name | -| `GIT_USER_EMAIL` | No | Git commit author email | -| `GIT_SIGNING_KEY` | No | SSH key name for commit signing | - -Example setup: - -```powershell -$env:GH_TOKEN = (gh auth token) -$env:GIT_USER_NAME = "Your Name" -$env:GIT_USER_EMAIL = "your.email@example.com" -$env:GIT_SIGNING_KEY = "id_ed25519" -``` - ---- - -## Customization - -### Project-Specific Worktree Hook - -Create `.devcontainer/worktree-up-hook.ps1` to run custom setup for each worktree. The hook is called every time `worktree-up.ps1` runs (both for new containers and reconnections). - -```powershell -# .devcontainer/worktree-up-hook.ps1 -param( - [string]$WorktreePath, - [string]$Branch, - [string]$MainRepo -) - -# Example: Create per-worktree .env file -$port = 8080 + (Get-Random -Maximum 1000) -"API_PORT=$port" | Out-File "$WorktreePath\.env" -Encoding utf8 -Write-Host " Created .env with port $port" -ForegroundColor Gray -``` - -**Parameters passed to hook:** -- `$WorktreePath` - Full path to the worktree directory -- `$Branch` - Branch name (or worktree name if different) -- `$MainRepo` - Path to the main repository - -### Adding Project-Specific Environment - -Edit `devcontainer.json` to add environment variables via `containerEnv` or `remoteEnv`: - -```json -"containerEnv": { - "MY_VAR": "value" -}, -"remoteEnv": { - "MY_TOKEN": "${localEnv:MY_TOKEN}" -} -``` - -### Changing the Default Model - -Edit `model` in `.devcontainer/copilot-config.json`: - -```json -{ - "model": "gpt-4o" -} -``` diff --git a/scripts/worktree-cleanup.ps1 b/scripts/worktree-cleanup.ps1 deleted file mode 100644 index 9cff7e1..0000000 --- a/scripts/worktree-cleanup.ps1 +++ /dev/null @@ -1,260 +0,0 @@ -# Copyright 2026 James Casey -# SPDX-License-Identifier: Apache-2.0 - -<# -.SYNOPSIS - Clean up orphaned devcontainers and volumes for this repository's worktrees. - -.DESCRIPTION - Finds and removes Docker containers and volumes that were created for worktrees - that no longer exist. Volumes in use are safely skipped. - -.PARAMETER All - Remove all containers for this project, even for active worktrees - -.PARAMETER DryRun - Show what would be removed without actually removing anything - -.EXAMPLE - .\worktree-cleanup.ps1 - -.EXAMPLE - .\worktree-cleanup.ps1 -All - -.EXAMPLE - .\worktree-cleanup.ps1 -DryRun -#> - -param( - [Parameter(Mandatory=$false)] - [switch]$All, - - [Parameter(Mandatory=$false)] - [switch]$DryRun, - - [Parameter(Mandatory=$false)] - [switch]$NoConfirm -) - -$ErrorActionPreference = "Stop" - -# Get project name from git repo -$repoRoot = git rev-parse --show-toplevel 2>$null -if (-not $repoRoot) { - Write-Host "โŒ Not in a git repository" -ForegroundColor Red - exit 1 -} -$projectName = Split-Path -Leaf $repoRoot - -Write-Host "๐Ÿณ Container Cleanup for: $projectName" -ForegroundColor Cyan -Write-Host "" - -# Get list of active worktree paths -$activeWorktrees = @() -$worktreeParentDir = $null -if (-not $All) { - Write-Host "๐Ÿ“‚ Finding active worktrees..." -ForegroundColor Cyan - $worktrees = git worktree list --porcelain 2>$null - $currentPath = $null - foreach ($line in $worktrees) { - if ($line -match "^worktree\s+(.+)") { - $currentPath = $matches[1] - $activeWorktrees += $currentPath - Write-Host " โœ“ $currentPath" -ForegroundColor Gray - # Derive the parent directory - if (-not $worktreeParentDir) { - $worktreeParentDir = (Split-Path -Parent $currentPath) -replace "\\", "/" - } - } - } - Write-Host "" -} - -# Find all project-related containers -Write-Host "๐Ÿ” Scanning Docker containers..." -ForegroundColor Cyan -$allContainers = docker ps -a --format "{{.ID}}|{{.Names}}|{{.Image}}|{{.Status}}" 2>$null - -$projectContainers = @() -$orphanedContainers = @() - -foreach ($line in $allContainers) { - if (-not $line) { continue } - - $parts = $line -split "\|" - $id = $parts[0] - $name = $parts[1] - $image = $parts[2] - $status = $parts[3] - - # Check if this is a container for THIS project's worktrees - # Match by: image name containing project, git.worktree label, or local_folder in our worktree parent directory - $worktreeLabel = docker inspect --format '{{index .Config.Labels "git.worktree"}}' $id 2>$null - $localFolder = docker inspect --format '{{index .Config.Labels "devcontainer.local_folder"}}' $id 2>$null - $normalizedLocalFolder = if ($localFolder) { $localFolder -replace "\\", "/" } else { "" } - - # Only consider containers that belong to THIS project's worktree directory - # Must match BOTH a project identifier AND be in our worktree directory structure - $isThisProject = $false - - # Primary check: local_folder must be within this repo's directory structure - $repoRootNormalized = $repoRoot -replace "\\", "/" - $isInRepoDir = $normalizedLocalFolder -and $normalizedLocalFolder -match [regex]::Escape($repoRootNormalized) - - if ($isInRepoDir) { - # Container's local_folder is within this repo - definitely ours - $isThisProject = $true - } elseif (($image -match [regex]::Escape($projectName) -or $image -match "vsc-$projectName") -and -not $normalizedLocalFolder) { - # Image matches project name and no local_folder to check - assume ours - # (legacy containers without labels) - $isThisProject = $true - } - - if ($isThisProject) { - # Get worktree name from label or local_folder - $worktreeName = if ($worktreeLabel) { - $worktreeLabel - } elseif ($localFolder) { - Split-Path -Leaf $localFolder - } else { - "unknown" - } - - $container = @{ - Id = $id - Name = $name - Image = $image - Status = $status - WorktreeLabel = $worktreeLabel - WorktreeName = $worktreeName - } - $projectContainers += $container - - # Check if it's orphaned (if not running --all mode) - if (-not $All) { - $isOrphaned = $true - - # Check by our custom git.worktree label first (most reliable) - if ($worktreeLabel) { - foreach ($worktree in $activeWorktrees) { - $worktreeName = Split-Path -Leaf $worktree - if ($worktreeName -eq $worktreeLabel) { - $isOrphaned = $false - break - } - } - } - # Fallback to devcontainer.local_folder label for containers created before label was added - else { - $labelJson = docker inspect --format '{{index .Config.Labels "devcontainer.local_folder"}}' $id 2>$null - if ($labelJson) { - foreach ($worktree in $activeWorktrees) { - $worktreeName = Split-Path -Leaf $worktree - $labelName = Split-Path -Leaf $labelJson - $normalizedWorktree = $worktree -replace "\\", "/" - $normalizedLabel = $labelJson -replace "\\", "/" - - if ($worktreeName -eq $labelName -or $normalizedLabel -match [regex]::Escape($normalizedWorktree)) { - $isOrphaned = $false - break - } - } - } - } - - if ($isOrphaned) { - $orphanedContainers += $container - } - } - } -} - -# Determine which containers to remove -$containersToRemove = if ($All) { $projectContainers } else { $orphanedContainers } - -Write-Host "Found:" -ForegroundColor White -Write-Host " Total project containers: $($projectContainers.Count)" -ForegroundColor Gray -if (-not $All) { - Write-Host " Active worktree containers: $($projectContainers.Count - $orphanedContainers.Count)" -ForegroundColor Green - Write-Host " Orphaned containers: $($orphanedContainers.Count)" -ForegroundColor Yellow -} -Write-Host "" - -if ($containersToRemove.Count -eq 0) { - Write-Host "โœจ No containers to remove!" -ForegroundColor Green - exit 0 -} - -# Display what will be removed -Write-Host "Will remove the following containers:" -ForegroundColor Yellow -foreach ($container in $containersToRemove) { - $statusColor = if ($container.Status -match "Up") { "Red" } else { "Gray" } - Write-Host " [$($container.Status)]" -ForegroundColor $statusColor -NoNewline - Write-Host " $($container.Name)" -ForegroundColor White -NoNewline - Write-Host " ($($container.WorktreeName))" -ForegroundColor DarkGray -} -Write-Host "" - -if ($DryRun) { - Write-Host "๐Ÿ” DRY RUN - No changes made" -ForegroundColor Cyan - exit 0 -} - -# Confirm before removing -if (-not $NoConfirm) { - $confirm = Read-Host "Remove these containers? (y/N)" - if ($confirm -ne 'y' -and $confirm -ne 'Y') { - Write-Host "โŒ Cancelled" -ForegroundColor Red - exit 0 - } -} - -# Remove containers -Write-Host "" -Write-Host "๐Ÿ—‘๏ธ Removing containers..." -ForegroundColor Cyan -$removed = 0 -foreach ($container in $containersToRemove) { - Write-Host " Stopping: $($container.Name)" -ForegroundColor Gray - docker stop $container.Id 2>$null | Out-Null - - Write-Host " Removing: $($container.Name)" -ForegroundColor Gray - docker rm $container.Id 2>$null | Out-Null - $removed++ -} -Write-Host "โœ… Removed $removed containers" -ForegroundColor Green - -# Find and remove orphaned volumes matching project name -Write-Host "" -Write-Host "๐Ÿ” Scanning for orphaned volumes..." -ForegroundColor Cyan -$allVolumes = docker volume ls --format "{{.Name}}" 2>$null - -$projectVolumes = @() -foreach ($volume in $allVolumes) { - # Devcontainer volumes typically include project name - if ($volume -match [regex]::Escape($projectName)) { - $projectVolumes += $volume - } -} - -if ($projectVolumes.Count -gt 0) { - Write-Host "Found $($projectVolumes.Count) project-related volumes" -ForegroundColor Gray - - $confirmVolumes = if ($NoConfirm) { "y" } else { Read-Host "Remove unused volumes? (y/N)" } - if ($confirmVolumes -eq 'y' -or $confirmVolumes -eq 'Y') { - foreach ($volume in $projectVolumes) { - # Try to remove - will fail if still in use - $result = docker volume rm $volume 2>&1 - if ($LASTEXITCODE -eq 0) { - Write-Host " โœ“ Removed: $volume" -ForegroundColor Green - } - else { - Write-Host " โš ๏ธ Skipped (in use): $volume" -ForegroundColor Yellow - } - } - } -} -else { - Write-Host "No orphaned volumes found" -ForegroundColor Gray -} - -Write-Host "" -Write-Host "โœจ Done!" -ForegroundColor Green diff --git a/scripts/worktree-down.ps1 b/scripts/worktree-down.ps1 deleted file mode 100644 index d80b569..0000000 --- a/scripts/worktree-down.ps1 +++ /dev/null @@ -1,333 +0,0 @@ -# Copyright 2026 James Casey -# SPDX-License-Identifier: Apache-2.0 - -<# -.SYNOPSIS - Stop and remove a git worktree and its devcontainer. - -.DESCRIPTION - Removes a worktree that may have been used with devcontainers. - - This script: - - Stops and removes the devcontainer for the worktree - - Fixes any container path issues in .git file - - Removes the worktree directory - - Optionally deletes the branch - - Note: Path normalization is applied to handle git's forward slashes - vs Docker's backslashes on Windows. - -.PARAMETER Branch - The branch name of the worktree to remove (e.g., "feature-branch") - -.PARAMETER Force - Force removal even if there are uncommitted changes - -.EXAMPLE - .\worktree-down.ps1 feature-branch - -.EXAMPLE - .\worktree-down.ps1 -Branch issue-123 -Force -#> - -param( - [Parameter(Position=0)] - [string]$Branch, - - [Parameter(Mandatory=$false)] - [switch]$Force -) - -$ErrorActionPreference = "Stop" - -# Show usage if no branch specified -if (-not $Branch) { - Write-Host "Usage: worktree-down.ps1 [-Force]" -ForegroundColor Cyan - Write-Host "" - Write-Host "Examples:" -ForegroundColor Gray - Write-Host " .\scripts\worktree-down.ps1 feature-branch" - Write-Host " .\scripts\worktree-down.ps1 feature-branch -Force" - exit 0 -} - -# Find the main repo root (where .git is a directory, not a file) -function Get-MainRepoRoot { - $current = Get-Location - - $gitPath = Join-Path $current ".git" - - if (Test-Path $gitPath -PathType Container) { - return $current.Path - } - elseif (Test-Path $gitPath -PathType Leaf) { - $gitContent = Get-Content $gitPath -Raw - if ($gitContent -match "gitdir:\s*(.+)") { - $gitDir = $matches[1].Trim() - $mainGit = Split-Path (Split-Path $gitDir -Parent) -Parent - return Split-Path $mainGit -Parent - } - } - - $gitRoot = git rev-parse --show-toplevel 2>$null - if ($gitRoot) { - return $gitRoot - } - - throw "Could not find git repository root" -} - -# Get worktree path for a branch -function Get-WorktreePath { - param( - [string]$BranchName, - [string]$WorktreeRoot - ) - - # Sanitize branch name to match directory name (same as worktree-up.ps1) - $dirName = $BranchName -replace '/', '-' - $expectedPath = Join-Path $WorktreeRoot $dirName - - # Check if this directory exists as a worktree - $worktrees = git worktree list --porcelain 2>$null - $wtPath = $null - foreach ($line in $worktrees) { - if ($line -match "^worktree\s+(.+)") { - $wtPath = $matches[1] - - # Convert container path to host path if needed - if ($wtPath -match "^/workspaces/(.+)$") { - $folderName = $matches[1] - $wtPath = Join-Path $WorktreeRoot $folderName - } - - # Match by directory name, not branch name - if ($wtPath -eq $expectedPath) { - return $wtPath - } - } - } - return $null -} - -# Stop and remove devcontainer for a worktree -function Remove-DevContainer { - param([string]$WorktreePath) - - $folderName = Split-Path $WorktreePath -Leaf - - # Find containers by our custom git.worktree label first (most reliable) - # Fall back to matching folder name in devcontainer.local_folder label - $allContainers = docker ps -a --format '{{.ID}}' 2>$null - $containers = @() - foreach ($id in $allContainers) { - if (-not $id) { continue } - - # Check our custom label first - $worktreeLabel = docker inspect --format '{{index .Config.Labels "git.worktree"}}' $id 2>$null - if ($worktreeLabel -eq $folderName) { - $containerName = docker inspect --format '{{.Name}}' $id 2>$null - $containers += "$id`t$containerName" - continue - } - - # Fallback to devcontainer.local_folder label - $labelPath = docker inspect --format '{{index .Config.Labels "devcontainer.local_folder"}}' $id 2>$null - if ($labelPath) { - $labelFolderName = Split-Path $labelPath -Leaf - if ($labelFolderName -eq $folderName) { - $containerName = docker inspect --format '{{.Name}}' $id 2>$null - $containers += "$id`t$containerName" - } - } - } - - if ($containers) { - Write-Host "๐Ÿณ Found devcontainer(s) for $folderName" -ForegroundColor Cyan - foreach ($container in $containers) { - $parts = $container -split "\t" - $containerId = $parts[0] - $containerName = $parts[1] - - Write-Host " Stopping: $containerName" -ForegroundColor Gray - docker stop $containerId 2>$null | Out-Null - - Write-Host " Removing: $containerName" -ForegroundColor Gray - docker rm $containerId 2>$null | Out-Null - } - Write-Host "โœ… Devcontainer(s) removed" -ForegroundColor Green - } - else { - Write-Host "โ„น๏ธ No devcontainer found for $folderName" -ForegroundColor Gray - } -} - -# Main script -Write-Host "๐Ÿ›‘ Stopping worktree for branch: $Branch" -ForegroundColor Cyan - -# Find main repo -$mainRepo = Get-MainRepoRoot -Write-Host "๐Ÿ“ Main repo: $mainRepo" -ForegroundColor Gray - -# Worktrees are in .worktrees/ subdirectory -$worktreeRoot = Join-Path $mainRepo ".worktrees" - -# Work from main repo -Push-Location $mainRepo -try { - # Find the worktree path - $worktreePath = Get-WorktreePath -BranchName $Branch -WorktreeRoot $worktreeRoot - - if (-not $worktreePath) { - Write-Host "โš ๏ธ No worktree found for branch: $Branch" -ForegroundColor Yellow - Write-Host " Checking for orphaned worktree metadata..." -ForegroundColor Gray - - # Check if there's orphaned metadata - $worktreeMetaPath = Join-Path ".git" "worktrees" $Branch - if (Test-Path $worktreeMetaPath) { - Write-Host "๐Ÿงน Found orphaned metadata, cleaning up..." -ForegroundColor Yellow - Remove-Item -Recurse -Force $worktreeMetaPath - Write-Host "โœ… Cleaned up orphaned worktree metadata" -ForegroundColor Green - } - else { - Write-Host "โŒ No worktree or metadata found for: $Branch" -ForegroundColor Red - } - exit 0 - } - - Write-Host "๐Ÿ“‚ Worktree path: $worktreePath" -ForegroundColor Gray - - # Show what branch is actually checked out (informational) - Push-Location $worktreePath - try { - $actualBranch = git branch --show-current 2>$null - if ($actualBranch -and $actualBranch -ne $Branch) { - Write-Host " Note: Currently on branch '$actualBranch' (worktree was created as '$Branch')" -ForegroundColor Gray - } - } - finally { - Pop-Location - } - - # Stop devcontainer FIRST (before removing worktree to release file locks) - Remove-DevContainer -WorktreePath $worktreePath - - # Run project-specific hook if it exists (for custom cleanup like volumes) - $hookScript = Join-Path $worktreePath ".devcontainer\worktree-down-hook.ps1" - if (Test-Path $hookScript) { - Write-Host "๐Ÿ”ง Running project cleanup hook..." -ForegroundColor Cyan - try { - & $hookScript -WorktreePath $worktreePath -Branch $Branch -MainRepo $mainRepo - if ($LASTEXITCODE -eq 0) { - Write-Host "โœ… Project cleanup hook completed" -ForegroundColor Green - } - else { - Write-Host "โš ๏ธ Project cleanup hook returned non-zero exit code" -ForegroundColor Yellow - } - } - catch { - Write-Host "โš ๏ธ Project cleanup hook failed: $_" -ForegroundColor Yellow - } - } - - # Check if the .git file in the worktree needs fixing - $gitFile = Join-Path $worktreePath ".git" - if (Test-Path $gitFile -PathType Leaf) { - $gitContent = Get-Content $gitFile -Raw - - # Check if it points to container path - if ($gitContent -match "/workspaces/") { - Write-Host "๐Ÿ”ง Fixing container path in .git file..." -ForegroundColor Yellow - - # Find the worktree name from the path - if ($gitContent -match "worktrees/([^/\r\n]+)") { - $worktreeName = $matches[1] - $correctPath = Join-Path $mainRepo ".git" "worktrees" $worktreeName - $correctPath = $correctPath -replace "\\", "/" - - Set-Content -Path $gitFile -Value "gitdir: $correctPath" -NoNewline - Write-Host "โœ… Fixed .git to point to: $correctPath" -ForegroundColor Green - } - } - } - elseif (-not (Test-Path $gitFile)) { - Write-Host "โš ๏ธ No .git file found in worktree (may be corrupted)" -ForegroundColor Yellow - } - - # Try standard worktree remove first - $removeArgs = @("worktree", "remove", $worktreePath) - if ($Force) { - $removeArgs += "--force" - } - - Write-Host "๐Ÿ—‘๏ธ Removing worktree..." -ForegroundColor Cyan - $result = & git @removeArgs 2>&1 - - if ($LASTEXITCODE -eq 0) { - Write-Host "โœ… Worktree removed successfully" -ForegroundColor Green - } - elseif ($result -match "modified or untracked files") { - Write-Host "โŒ Worktree has uncommitted changes" -ForegroundColor Red - Write-Host " Use -Force to delete anyway, or commit/stash your changes first." -ForegroundColor Yellow - exit 1 - } - else { - Write-Host "โš ๏ธ git worktree remove failed: $result" -ForegroundColor Yellow - Write-Host "๐Ÿ”ง Attempting manual cleanup..." -ForegroundColor Cyan - - # Manual cleanup - $worktreeMetaPath = Join-Path ".git" "worktrees" $Branch - - # Also try with the worktree directory name if different from branch - $worktreeDirName = Split-Path $worktreePath -Leaf - $worktreeMetaPathAlt = Join-Path ".git" "worktrees" $worktreeDirName - - # Remove metadata - foreach ($metaPath in @($worktreeMetaPath, $worktreeMetaPathAlt)) { - if (Test-Path $metaPath) { - Write-Host " Removing: $metaPath" -ForegroundColor Gray - Remove-Item -Recurse -Force $metaPath - } - } - - # Remove worktree directory - if (Test-Path $worktreePath) { - Write-Host " Removing: $worktreePath" -ForegroundColor Gray - Remove-Item -Recurse -Force $worktreePath - } - - # Prune stale entries - git worktree prune - - Write-Host "โœ… Manual cleanup complete" -ForegroundColor Green - } - - # Optionally delete the branch - Write-Host "" - $deleteBranch = Read-Host "Delete local branch '$Branch'? (y/N)" - if ($deleteBranch -eq 'y' -or $deleteBranch -eq 'Y') { - # Update main branch to check against latest remote state - Write-Host "๐Ÿ”„ Updating main branch..." -ForegroundColor Cyan - git fetch origin main:main 2>$null - if ($LASTEXITCODE -ne 0) { - # Fallback if fast-forward fails - git fetch origin main 2>$null - } - - git branch -d $Branch 2>$null - if ($LASTEXITCODE -ne 0) { - Write-Host "โš ๏ธ Branch not fully merged. Force delete? (y/N)" -ForegroundColor Yellow - $forceDelete = Read-Host - if ($forceDelete -eq 'y' -or $forceDelete -eq 'Y') { - git branch -D $Branch - } - } - if ($LASTEXITCODE -eq 0) { - Write-Host "โœ… Branch deleted" -ForegroundColor Green - } - } -} -finally { - Pop-Location -} - -Write-Host "`nโœจ Done!" -ForegroundColor Green diff --git a/scripts/worktree-status.ps1 b/scripts/worktree-status.ps1 deleted file mode 100644 index 378f011..0000000 --- a/scripts/worktree-status.ps1 +++ /dev/null @@ -1,168 +0,0 @@ -# Copyright 2026 James Casey -# SPDX-License-Identifier: Apache-2.0 - -<# -.SYNOPSIS - Show status of all git worktrees and their devcontainers. - -.DESCRIPTION - Lists all worktrees in the repository and shows which ones have - running or stopped devcontainers. - -.EXAMPLE - .\worktree-status.ps1 -#> - -$ErrorActionPreference = "Stop" - -# Find the main repo root (where .git is a directory, not a file) -function Get-MainRepoRoot { - $current = Get-Location - - $gitPath = Join-Path $current ".git" - - if (Test-Path $gitPath -PathType Container) { - return $current.Path - } - elseif (Test-Path $gitPath -PathType Leaf) { - $gitContent = Get-Content $gitPath -Raw - if ($gitContent -match "gitdir:\s*(.+)") { - $gitDir = $matches[1].Trim() - $mainGit = Split-Path (Split-Path $gitDir -Parent) -Parent - return Split-Path $mainGit -Parent - } - } - - $gitRoot = git rev-parse --show-toplevel 2>$null - if ($gitRoot) { - return $gitRoot - } - - throw "Could not find git repository root" -} - -# Get container status for a worktree -function Get-ContainerStatus { - param([string]$WorktreeName) - - # Fetch all container info including labels in a single docker ps call - # Format: ID|git.worktree label|devcontainer.local_folder label|Status - $allContainers = docker ps -a --format '{{.ID}}|{{.Label "git.worktree"}}|{{.Label "devcontainer.local_folder"}}|{{.Status}}' 2>$null - foreach ($line in $allContainers) { - if (-not $line) { continue } - - $parts = $line -split '\|' - $id = $parts[0] - $worktreeLabel = $parts[1] - $localFolder = $parts[2] - $status = $parts[3] - - # Check our custom git.worktree label - if ($worktreeLabel -eq $WorktreeName) { - if ($status -match "^Up") { - return "๐ŸŸข Running" - } else { - return "๐Ÿ”ด Stopped" - } - } - - # Fallback to devcontainer.local_folder label - if ($localFolder) { - $labelName = Split-Path -Leaf $localFolder - if ($labelName -eq $WorktreeName) { - if ($status -match "^Up") { - return "๐ŸŸข Running" - } else { - return "๐Ÿ”ด Stopped" - } - } - } - } - - return "โšช No container" -} - -# Main script -$mainRepo = Get-MainRepoRoot -$projectName = Split-Path $mainRepo -Leaf - -Write-Host "๐Ÿ“‚ Worktree Status for: $projectName" -ForegroundColor Cyan -Write-Host "" - -# Get all worktrees -Push-Location $mainRepo -try { - $worktrees = git worktree list --porcelain 2>$null - - $currentWorktree = $null - $currentBranch = $null - $results = @() - - foreach ($line in $worktrees) { - if ($line -match "^worktree\s+(.+)") { - # New worktree entry - save previous if exists - if ($currentWorktree) { - $worktreeName = Split-Path $currentWorktree -Leaf - $normalizedWorktree = $currentWorktree -replace '/', '\' - $normalizedMain = $mainRepo -replace '/', '\' - $isMain = $normalizedWorktree -eq $normalizedMain - - $results += @{ - Path = $currentWorktree - Name = $worktreeName - Branch = if ($currentBranch) { $currentBranch } else { "(detached)" } - IsMain = $isMain - Container = if ($isMain) { "-" } else { Get-ContainerStatus -WorktreeName $worktreeName } - } - } - $currentWorktree = $matches[1] - $currentBranch = $null - } - elseif ($line -match "^branch\s+refs/heads/(.+)") { - $currentBranch = $matches[1] - } - } - - # Don't forget the last entry - if ($currentWorktree) { - $worktreeName = Split-Path $currentWorktree -Leaf - $normalizedWorktree = $currentWorktree -replace '/', '\' - $normalizedMain = $mainRepo -replace '/', '\' - $isMain = $normalizedWorktree -eq $normalizedMain - - $results += @{ - Path = $currentWorktree - Name = $worktreeName - Branch = if ($currentBranch) { $currentBranch } else { "(detached)" } - IsMain = $isMain - Container = if ($isMain) { "-" } else { Get-ContainerStatus -WorktreeName $worktreeName } - } - } - - # Display results - if ($results.Count -eq 0) { - Write-Host "No worktrees found." -ForegroundColor Gray - } - else { - # Header - Write-Host ("{0,-20} {1,-20} {2}" -f "BRANCH", "WORKTREE", "CONTAINER") -ForegroundColor White - Write-Host ("{0,-20} {1,-20} {2}" -f "------", "--------", "---------") -ForegroundColor DarkGray - - foreach ($wt in $results) { - $branchDisplay = $wt.Branch - $nameDisplay = if ($wt.IsMain) { "(main repo)" } else { $wt.Name } - $containerDisplay = $wt.Container - - $branchColor = if ($wt.IsMain) { "DarkGray" } else { "Yellow" } - - Write-Host ("{0,-20}" -f $branchDisplay) -ForegroundColor $branchColor -NoNewline - Write-Host (" {0,-20}" -f $nameDisplay) -ForegroundColor Gray -NoNewline - Write-Host (" {0}" -f $containerDisplay) - } - } - - Write-Host "" -} -finally { - Pop-Location -} diff --git a/scripts/worktree-up.ps1 b/scripts/worktree-up.ps1 deleted file mode 100644 index d0a75c3..0000000 --- a/scripts/worktree-up.ps1 +++ /dev/null @@ -1,695 +0,0 @@ -# Copyright 2026 James Casey -# SPDX-License-Identifier: Apache-2.0 - -<# -.SYNOPSIS - Start a Copilot devcontainer session for a git worktree branch. - -.DESCRIPTION - Creates a worktree for the given branch (if needed) and starts - a devcontainer. Each worktree gets its own container for parallel - Copilot sessions. - -.PARAMETER Branch - The branch name to work on (e.g., "feature-branch" or "main") - -.PARAMETER BaseBranch - The base branch to create new branches from (default: auto-detect main/master) - -.PARAMETER WorktreeRoot - Directory where worktrees are created (default: .worktrees/ inside main repo) - -.PARAMETER Command - Command to run in container (default: "copilot") - -.PARAMETER Amp - Start Amp instead of Copilot (sets Command to "amp") - -.PARAMETER Rebuild - Force rebuild of the devcontainer image (no cache) - -.EXAMPLE - .\worktree-up.ps1 feature-branch - -.EXAMPLE - .\worktree-up.ps1 -Branch issue-123 -BaseBranch main - -.EXAMPLE - .\worktree-up.ps1 feature-branch -Command bash - -.EXAMPLE - .\worktree-up.ps1 feature-branch -Amp - -.EXAMPLE - .\worktree-up.ps1 feature-branch -Rebuild -#> - -param( - [Parameter(Position=0)] - [string]$Branch, - - [Parameter(Mandatory=$false)] - [string]$BaseBranch = "", - - [Parameter(Mandatory=$false)] - [string]$WorktreeRoot = "", - - [Parameter(Mandatory=$false)] - [string]$Command = "copilot --yolo", - - [Parameter(Mandatory=$false)] - [switch]$Amp, - - [Parameter(Mandatory=$false)] - [switch]$Rebuild -) - -if ($Amp -and $PSBoundParameters.ContainsKey('Command')) { - Write-Error "-Amp and -Command are mutually exclusive. Use one or the other." - exit 1 -} - -if ($Amp) { - $Command = "amp" -} - -$ErrorActionPreference = "Stop" - -# Show usage if no branch specified -if (-not $Branch) { - Write-Host "Usage: worktree-up.ps1 [-BaseBranch ] [-Command ] [-Amp] [-Rebuild]" -ForegroundColor Cyan - Write-Host "" - Write-Host "Examples:" -ForegroundColor Gray - Write-Host " .\scripts\worktree-up.ps1 feature-branch" - Write-Host " .\scripts\worktree-up.ps1 issue-123 -BaseBranch main" - Write-Host " .\scripts\worktree-up.ps1 feature-branch -Command bash" - Write-Host " .\scripts\worktree-up.ps1 feature-branch -Amp" - Write-Host " .\scripts\worktree-up.ps1 feature-branch -Rebuild" - exit 0 -} - -# Get default branch (main or master) -function Get-DefaultBranch { - # Try to get from remote HEAD - $remoteBranch = git symbolic-ref refs/remotes/origin/HEAD 2>$null - if ($remoteBranch -match "refs/remotes/origin/(.+)") { - return $matches[1] - } - # Fallback: check if main exists, else master - if (git rev-parse --verify main 2>$null) { - return "main" - } - return "master" -} - -# Find the main repo root (where .git is a directory, not a file) -function Get-MainRepoRoot { - $current = Get-Location - - # Check if we're in a worktree (has .git file) or main repo (has .git dir) - $gitPath = Join-Path $current ".git" - - if (Test-Path $gitPath -PathType Container) { - # We're in the main repo - return $current.Path - } - elseif (Test-Path $gitPath -PathType Leaf) { - # We're in a worktree, read the .git file to find main repo - $gitContent = Get-Content $gitPath -Raw - if ($gitContent -match "gitdir:\s*(.+)") { - $gitDir = $matches[1].Trim() - # gitDir points to .git/worktrees/name, go up to .git then to repo - $mainGit = Split-Path (Split-Path $gitDir -Parent) -Parent - return Split-Path $mainGit -Parent - } - } - - # Try to find it via git - $gitRoot = git rev-parse --show-toplevel 2>$null - if ($gitRoot) { - return $gitRoot - } - - throw "Could not find git repository root" -} - -# Get current branch name -function Get-CurrentBranch { - return (git branch --show-current 2>$null) -} - -# Check if branch exists (local or remote) -function Test-BranchExists { - param([string]$BranchName) - - $local = git branch --list $BranchName 2>$null - if ($local) { return $true } - - $remote = git branch -r --list "origin/$BranchName" 2>$null - if ($remote) { return $true } - - return $false -} - -# Check if worktree exists for branch -function Get-WorktreePath { - param( - [string]$BranchName, - [string]$WorktreeRoot - ) - - # Sanitize branch name to match directory name - $dirName = $BranchName -replace '/', '-' - $expectedPath = Join-Path $WorktreeRoot $dirName - - # Check if this directory exists as a worktree - $worktrees = git worktree list --porcelain 2>$null - $wtPath = $null - foreach ($line in $worktrees) { - if ($line -match "^worktree\s+(.+)") { - $wtPath = $matches[1] - - # Convert container path to host path if needed - if ($wtPath -match "^/workspaces/(.+)$") { - $folderName = $matches[1] - $wtPath = Join-Path $WorktreeRoot $folderName - } - - # Match by directory name, not branch name - if ($wtPath -eq $expectedPath) { - return $wtPath - } - } - } - return $null -} - -# Get project name from repository root folder name -function Get-ProjectName { - param([string]$RepoPath) - return Split-Path $RepoPath -Leaf -} - -# Run project-specific setup hook inside container -function Invoke-WorktreeUpHook { - param([string]$WorktreePath) - - $upHookScript = Join-Path $WorktreePath ".devcontainer" "worktree-up-hook.sh" - if (Test-Path $upHookScript) { - Write-Host "๐Ÿ”ง Running project setup hook..." -ForegroundColor Cyan - $hookOutput = & devcontainer exec --workspace-folder $WorktreePath bash .devcontainer/worktree-up-hook.sh 2>&1 - if ($LASTEXITCODE -eq 0) { - Write-Host "โœ… Project setup hook completed" -ForegroundColor Green - } - else { - Write-Host "โš ๏ธ Project setup hook failed" -ForegroundColor Yellow - if ($hookOutput) { - Write-Host "Hook output:" -ForegroundColor DarkYellow - Write-Host $hookOutput - } - } - } -} - -# Check if a devcontainer is running for a worktree path -function Test-ContainerRunning { - param([string]$WorktreePath) - - # Get container ID for this workspace - $containerList = docker ps --filter "label=devcontainer.local_folder=$WorktreePath" --format "{{.ID}}" 2>$null - return ($containerList -and $containerList.Trim() -ne "") -} - -# Main script -Write-Host "๐Ÿš€ Starting worktree session: $Branch" -ForegroundColor Cyan - -# Find main repo -$mainRepo = Get-MainRepoRoot -$projectName = Get-ProjectName -RepoPath $mainRepo -Write-Host "๐Ÿ“ Main repo: $mainRepo" -ForegroundColor Gray -Write-Host "๐Ÿ“ Project: $projectName" -ForegroundColor Gray - -# Set worktree root if not specified (default: .worktrees/ inside repo) -if (-not $WorktreeRoot) { - $WorktreeRoot = Join-Path $mainRepo ".worktrees" -} - -# Sanitize name for directory (replace / with -) -$dirName = $Branch -replace '/', '-' -$worktreePath = Join-Path $WorktreeRoot $dirName - -# FAST PATH: If container is already running for this worktree, just reconnect -# This handles the case where you've switched branches inside the container -if (Test-Path $worktreePath) { - if (Test-ContainerRunning -WorktreePath $worktreePath) { - if ($Rebuild) { - Write-Host "โš ๏ธ Container already running โ€” cannot rebuild while running." -ForegroundColor Yellow - Write-Host " Run: .\scripts\worktree-down.ps1 $Branch" -ForegroundColor Gray - Write-Host " Then re-run with -Rebuild" -ForegroundColor Gray - exit 1 - } - - Write-Host "โœ… Container already running for worktree: $dirName" -ForegroundColor Green - - # Show what branch is actually checked out (informational only) - Push-Location $worktreePath - try { - $actualBranch = git branch --show-current 2>$null - if ($actualBranch -and $actualBranch -ne $Branch) { - Write-Host " Note: Currently on branch '$actualBranch' (worktree was created as '$Branch')" -ForegroundColor Gray - } - } - finally { - Pop-Location - } - - # Skip to container connection (jump past worktree setup) - $skipWorktreeSetup = $true - } -} - -if (-not $skipWorktreeSetup) { - # Ensure worktree root exists - if (-not (Test-Path $WorktreeRoot)) { - Write-Host "๐Ÿ“ Creating .worktrees directory..." -ForegroundColor Cyan - New-Item -ItemType Directory -Path $WorktreeRoot -Force | Out-Null - } - - # Set base branch if not specified - if (-not $BaseBranch) { - Push-Location $mainRepo - $BaseBranch = Get-DefaultBranch - Pop-Location - Write-Host "๐Ÿ“Œ Using default branch: $BaseBranch" -ForegroundColor Gray - } - - # Check if we're already on this branch in current directory -$currentBranch = Get-CurrentBranch -$currentDir = Get-Location - -# Always fetch latest from origin first -Write-Host "๐Ÿ”„ Fetching latest from origin..." -ForegroundColor Cyan -Push-Location $mainRepo -try { - git fetch origin --prune 2>$null -} -finally { - Pop-Location -} - -if ($currentBranch -eq $Branch) { - Write-Host "โœ… Already on branch $Branch in current directory" -ForegroundColor Green - $worktreePath = $currentDir.Path -} -else { - # Check if worktree already exists - Push-Location $mainRepo - try { - $existingWorktree = Get-WorktreePath -BranchName $Branch -WorktreeRoot $WorktreeRoot - - if ($existingWorktree -and (Test-Path $existingWorktree)) { - # Worktree exists and is accessible from Windows - Write-Host "โœ… Worktree already exists at: $existingWorktree" -ForegroundColor Green - $worktreePath = $existingWorktree - } - elseif ($existingWorktree) { - # Worktree metadata exists but path is inaccessible (likely orphaned container path) - Write-Host "โš ๏ธ Worktree metadata exists but path inaccessible: $existingWorktree" -ForegroundColor Yellow - Write-Host "๐Ÿ”„ Removing orphaned worktree and recreating..." -ForegroundColor Cyan - git worktree remove $Branch --force 2>$null - - # Create new worktree at proper Windows path - $dirName = $Branch -replace '/', '-' - $worktreePath = Join-Path $WorktreeRoot $dirName - - # Remove directory if it exists (may be leftover from previous container) - if (Test-Path $worktreePath) { - Write-Host "๐Ÿ—‘๏ธ Removing existing directory: $worktreePath" -ForegroundColor Gray - try { - Remove-Item -Path $worktreePath -Recurse -Force -ErrorAction Stop - } - catch { - Write-Host "โŒ Cannot remove directory (may be in use by container)" -ForegroundColor Red - Write-Host " Run: .\scripts\worktree-down.ps1 $Branch" -ForegroundColor Gray - Write-Host " Or: docker stop " -ForegroundColor Gray - exit 1 - } - } - - Write-Host "๐Ÿ“Œ Recreating worktree for branch: $Branch" -ForegroundColor Yellow - git worktree add $worktreePath $Branch - - if ($LASTEXITCODE -ne 0) { - Write-Host "โŒ Failed to recreate worktree" -ForegroundColor Red - exit 1 - } - - Write-Host "โœ… Worktree recreated at: $worktreePath" -ForegroundColor Green - } - else { - # Create new worktree - # Sanitize branch name for directory (replace / with -) - $dirName = $Branch -replace '/', '-' - $worktreePath = Join-Path $WorktreeRoot $dirName - - if (Test-BranchExists -BranchName $Branch) { - Write-Host "๐Ÿ“Œ Creating worktree for existing branch: $Branch" -ForegroundColor Yellow - git worktree add $worktreePath $Branch - } - else { - Write-Host "๐ŸŒฑ Creating worktree with new branch: $Branch (from $BaseBranch)" -ForegroundColor Yellow - git worktree add -b $Branch $worktreePath $BaseBranch - } - - if ($LASTEXITCODE -ne 0) { - Write-Host "โŒ Failed to create worktree" -ForegroundColor Red - exit 1 - } - - Write-Host "โœ… Worktree created at: $worktreePath" -ForegroundColor Green - } - } - finally { - Pop-Location - } -} -} # End of: if (-not $skipWorktreeSetup) for worktree creation - -# Update worktree with latest changes from remote (skip if container already running) -if (-not $skipWorktreeSetup) { - if (Test-Path $worktreePath) { - Write-Host "๐Ÿ”„ Updating worktree with latest changes..." -ForegroundColor Cyan - Push-Location $worktreePath - try { - $trackingBranch = git rev-parse --abbrev-ref "@{upstream}" 2>$null - if ($trackingBranch) { - git pull --ff-only 2>$null - if ($LASTEXITCODE -eq 0) { - Write-Host "โœ… Worktree updated" -ForegroundColor Green - } - else { - Write-Host "โš ๏ธ Could not fast-forward, may need manual merge" -ForegroundColor Yellow - } - } - else { - Write-Host "โ„น๏ธ No upstream tracking branch, skipping pull" -ForegroundColor Gray - } - } - finally { - Pop-Location - } -} -else { - Write-Host "โ„น๏ธ Worktree path not accessible from host, will update in container" -ForegroundColor Gray -} -} # End of: if (-not $skipWorktreeSetup) - -# Check for devcontainer CLI -$devcontainerCli = Get-Command devcontainer -ErrorAction SilentlyContinue -if (-not $devcontainerCli) { - Write-Host "โš ๏ธ devcontainer CLI not found. Installing..." -ForegroundColor Yellow - npm install -g @devcontainers/cli -} - -# Check GH_TOKEN is set (required for GitHub authentication in container) -if (-not $env:GH_TOKEN) { - Write-Host "โŒ GH_TOKEN not set. Required for GitHub authentication." -ForegroundColor Red - Write-Host " Authenticate with: gh auth login" -ForegroundColor Gray - Write-Host " Then set: `$env:GH_TOKEN = (gh auth token)" -ForegroundColor Gray - exit 1 -} - -# Set MAIN_GIT_PATH (auto-derived from main repo) -$env:MAIN_GIT_PATH = Join-Path $mainRepo ".git" - -# These variables are needed for both setup and reconnect paths -$worktreeName = Split-Path $worktreePath -Leaf -$worktreeGitFile = Join-Path $worktreePath ".git" -$worktreeMetaGitdir = Join-Path $env:MAIN_GIT_PATH "worktrees" $worktreeName "gitdir" - -# Skip container setup if already running - just reconnect -if ($skipWorktreeSetup) { - Write-Host "`n๐Ÿ”Œ Reconnecting to existing container..." -ForegroundColor Cyan - - # IMPORTANT: Pre-fix git paths for container before exec - # The finally block resets paths to host format after each run, - # so we must convert them back to container format before reconnecting - Write-Host "๐Ÿ”ง Pre-fixing .git paths for container..." -ForegroundColor Cyan - $containerGitPath = "/workspaces/.$projectName-git/worktrees/$worktreeName" - Set-Content -Path $worktreeGitFile -Value "gitdir: $containerGitPath" -NoNewline - Write-Host " Set .git to: $containerGitPath" -ForegroundColor Gray - - if (Test-Path $worktreeMetaGitdir) { - Set-Content -Path $worktreeMetaGitdir -Value "/workspaces/$worktreeName" -NoNewline - Write-Host " Set gitdir to: /workspaces/$worktreeName" -ForegroundColor Gray - } - Write-Host "โœ… Git paths pre-configured for container" -ForegroundColor Green - - Invoke-WorktreeUpHook -WorktreePath $worktreePath -} -else { - # Sync devcontainer config from main repo (worktrees may be created from old commits) - Write-Host "๐Ÿ“‹ Syncing devcontainer config..." -ForegroundColor Cyan - $mainDevcontainer = Join-Path $mainRepo ".devcontainer" - $worktreeDevcontainer = Join-Path $worktreePath ".devcontainer" - if (Test-Path $mainDevcontainer) { - Copy-Item -Path "$mainDevcontainer\*" -Destination $worktreeDevcontainer -Force -Recurse - Write-Host "โœ… Synced .devcontainer from main repo" -ForegroundColor Green - } - - # Pre-fix worktree .git file for container paths BEFORE starting container - # This prevents "fatal: not a git repository" errors during postStartCommand - Write-Host "๐Ÿ”ง Pre-fixing .git paths for container..." -ForegroundColor Cyan - # IMPORTANT: Point to the worktree-specific git directory - # The .git file contains "gitdir: " where is inside .git/worktrees/ - $containerGitPath = "/workspaces/.$projectName-git/worktrees/$worktreeName" - Set-Content -Path $worktreeGitFile -Value "gitdir: $containerGitPath" -NoNewline - Write-Host " Set .git to: $containerGitPath" -ForegroundColor Gray - - # Also fix the gitdir file in the main repo's worktree metadata - if (Test-Path $worktreeMetaGitdir) { - Set-Content -Path $worktreeMetaGitdir -Value "/workspaces/$worktreeName" -NoNewline - Write-Host " Set gitdir to: /workspaces/$worktreeName" -ForegroundColor Gray - } -Write-Host "โœ… Git paths pre-configured for container" -ForegroundColor Green - -# Start devcontainer -Write-Host "`n๐Ÿณ Starting devcontainer..." -ForegroundColor Cyan -Write-Host " Workspace: $worktreePath" -ForegroundColor Gray - -# Suppress Docker CLI hints (e.g., "Try Docker Debug...") -$env:DOCKER_CLI_HINTS = "false" - -# Build and start the container - capture output to get container ID -# Explicitly mount the main .git directory since ${localEnv:...} doesn't work reliably -$mountArg = "--mount=type=bind,source=$($env:MAIN_GIT_PATH),target=/workspaces/.$projectName-git" -$rebuildArgs = if ($Rebuild) { "--remove-existing-container" } else { "" } -$displayCmd = "devcontainer up --workspace-folder $worktreePath $mountArg $rebuildArgs".Trim() -Write-Host "Running: $displayCmd" -ForegroundColor Gray - -$upArgs = @("up", "--workspace-folder", $worktreePath, $mountArg) -if ($Rebuild) { - $upArgs += "--remove-existing-container" - Write-Host "๐Ÿ”จ Rebuilding devcontainer (removing existing container)..." -ForegroundColor Yellow -} - -$output = & devcontainer @upArgs 2>&1 | ForEach-Object { - # Filter out PowerShell's stderr wrapper noise and Docker hints - if ($_ -is [System.Management.Automation.ErrorRecord]) { - $line = $_.Exception.Message - } else { - $line = $_ - } - # Strip devcontainer CLI timestamp prefix (e.g., "[2026-02-06T13:41:03.568Z] ") - # so downstream regex filters can match the actual content - $stripped = $line -replace '^\[[\d\-T:.Z]+\]\s*', '' - # Suppress Docker build noise (layer steps, apt output, download progress, etc.) - # Keep devcontainer lifecycle output (postCreateCommand, postStartCommand, etc.) - if ($stripped -and - $stripped -notmatch '^System\.Management\.Automation\.RemoteException' -and - $stripped -notmatch "What's next:|Try Docker Debug|Learn more at https://docs\.docker\.com" -and - $stripped -notmatch '^\s*#\d+\s' -and # Docker BuildKit step lines (#1, #2 [internal], etc.) - $stripped -notmatch '^\s*--->' -and # Legacy Docker builder layer IDs - $stripped -notmatch '^(Step \d+/\d+|Removing intermediate|Successfully (built|tagged))' -and - $stripped -notmatch '^(Sending build context|COPY|RUN|FROM|ENV|WORKDIR|ARG|LABEL|EXPOSE|CMD|ENTRYPOINT|ADD|VOLUME|USER|SHELL|ONBUILD|STOPSIGNAL|HEALTHCHECK)' -and - $stripped -notmatch '^\s*(Get:|Hit:|Ign:|Fetched |Reading |Building )' -and # apt-get output - $stripped -notmatch '^\s*(\d+\.\d+ [kMG]B|Downloading|Unpacking|Setting up|Selecting|Preparing|Processing)' -and - $stripped -notmatch '^\s*(sha256:|digest:|resolve |resolved |DONE |CACHED )' -and - $stripped -notmatch '^\[[\d/ ]+\]') { # Docker progress like [1/5] - Write-Host $stripped - } - $line # Pass through original (with timestamp) to capture for JSON parsing -} - -if ($LASTEXITCODE -ne 0) { - Write-Host "`nโŒ Failed to start devcontainer" -ForegroundColor Red - exit 1 -} - -# Extract container ID from output JSON (last line) -$containerIdFromOutput = $null -try { - $lastLine = ($output | Select-Object -Last 1) -replace '\x1b\[[0-9;]*m', '' # Strip ANSI codes - $jsonOutput = $lastLine | ConvertFrom-Json -ErrorAction SilentlyContinue - if ($jsonOutput.containerId) { - $containerIdFromOutput = $jsonOutput.containerId - } -} catch { - # Ignore JSON parse errors -} - -Write-Host "`nโœจ Container ready!" -ForegroundColor Green - -# Set up Copilot CLI config -Write-Host "๐Ÿค– Setting up Copilot CLI config..." -ForegroundColor Cyan -devcontainer exec --workspace-folder $worktreePath bash -c "mkdir -p ~/.copilot && envsubst < .devcontainer/mcp-config.json > ~/.copilot/mcp-config.json && envsubst < .devcontainer/copilot-config.json > ~/.copilot/config.json" 2>$null -if ($LASTEXITCODE -eq 0) { - Write-Host "โœ… Copilot CLI configured" -ForegroundColor Green -} -else { - Write-Host "โš ๏ธ Could not configure Copilot CLI" -ForegroundColor Yellow -} - -# Set up Amp CLI config -Write-Host "โšก Setting up Amp CLI config..." -ForegroundColor Cyan -devcontainer exec --workspace-folder $worktreePath bash -c "mkdir -p ~/.config/amp && envsubst < .devcontainer/amp-settings.json > ~/.config/amp/settings.json" 2>$null -if ($LASTEXITCODE -eq 0) { - Write-Host "โœ… Amp CLI configured" -ForegroundColor Green -} -else { - Write-Host "โš ๏ธ Could not configure Amp CLI" -ForegroundColor Yellow -} - -Invoke-WorktreeUpHook -WorktreePath $worktreePath - -# Copy SSH signing key if configured -if ($env:GIT_SIGNING_KEY) { - # For signing, we need the private key (not .pub) - # If user specified id_rsa.pub, use id_rsa instead - $keyName = $env:GIT_SIGNING_KEY -replace '\.pub$', '' - $privateKeyPath = Join-Path $env:USERPROFILE ".ssh\$keyName" - $publicKeyPath = Join-Path $env:USERPROFILE ".ssh\$keyName.pub" - - if ((Test-Path $privateKeyPath) -and (Test-Path $publicKeyPath)) { - Write-Host "๐Ÿ”‘ Copying SSH signing keys..." -ForegroundColor Cyan - - # Use container ID from devcontainer up output - $containerInfo = $containerIdFromOutput - - if ($containerInfo) { - Write-Host " Container ID: $containerInfo" -ForegroundColor Gray - - # Ensure .ssh directory exists in container with correct ownership - docker exec $containerInfo mkdir -p /home/vscode/.ssh 2>$null - docker exec $containerInfo chown -R vscode:vscode /home/vscode/.ssh 2>$null - docker exec $containerInfo chmod 700 /home/vscode/.ssh 2>$null - - # Copy private key - Write-Host " Copying private key: $privateKeyPath" -ForegroundColor Gray - $privateKeyContent = Get-Content $privateKeyPath -Raw - $privateKeyContent | docker exec -i $containerInfo tee /home/vscode/.ssh/$keyName 2>&1 | Out-Null - - if ($LASTEXITCODE -eq 0) { - # Set correct permissions for private key (600) - docker exec $containerInfo chown vscode:vscode /home/vscode/.ssh/$keyName 2>$null - docker exec $containerInfo chmod 600 /home/vscode/.ssh/$keyName 2>$null - - # Copy public key - Write-Host " Copying public key: $publicKeyPath" -ForegroundColor Gray - $publicKeyContent = Get-Content $publicKeyPath -Raw - $publicKeyContent | docker exec -i $containerInfo tee /home/vscode/.ssh/$keyName.pub 2>&1 | Out-Null - - # Set correct permissions for public key (644) - docker exec $containerInfo chown vscode:vscode /home/vscode/.ssh/$keyName.pub 2>$null - docker exec $containerInfo chmod 644 /home/vscode/.ssh/$keyName.pub 2>$null - - Write-Host "โœ… SSH keys copied, configuring git signing..." -ForegroundColor Green - - # Configure git to use SSH signing with the private key - devcontainer exec --workspace-folder $worktreePath git config --global gpg.format ssh - devcontainer exec --workspace-folder $worktreePath git config --global user.signingkey /home/vscode/.ssh/$keyName - devcontainer exec --workspace-folder $worktreePath git config --global commit.gpgsign true - devcontainer exec --workspace-folder $worktreePath git config --global tag.gpgsign true - - Write-Host "โœ… Git signing configured" -ForegroundColor Green - } - else { - Write-Host "โš ๏ธ Could not copy SSH keys to container" -ForegroundColor Yellow - } - } - else { - Write-Host "โš ๏ธ Could not find running container" -ForegroundColor Yellow - } - } - else { - Write-Host "โš ๏ธ SSH keys not found. Need both:" -ForegroundColor Yellow - Write-Host " Private: $privateKeyPath" -ForegroundColor Yellow - Write-Host " Public: $publicKeyPath" -ForegroundColor Yellow - } -} -} # End of: if (-not $skipWorktreeSetup) - -# Check AMP_API_KEY if using Amp -if ($Amp -and -not $env:AMP_API_KEY) { - Write-Host "โŒ AMP_API_KEY not set. Required for Amp." -ForegroundColor Red - Write-Host " Sign in at: ampcode.com/install" -ForegroundColor Gray - Write-Host " Then set: `$env:AMP_API_KEY = " -ForegroundColor Gray - exit 1 -} - -Write-Host "`nStarting: $Command" -ForegroundColor Cyan - -# Connect to the container and run the command -# Use try/finally to ensure git paths are reset when command exits -try { - $cmdArgs = @("exec", "--workspace-folder", $worktreePath) + ($Command -split '\s+') - & devcontainer @cmdArgs -} -finally { - # Reset git paths back to host paths - Write-Host "`n๐Ÿ”ง Resetting .git paths for host..." -ForegroundColor Cyan - - # Reset worktree .git file to point to host path - # (retry a few times in case file is briefly locked by container shutdown) - $hostGitPath = Join-Path $env:MAIN_GIT_PATH "worktrees" $worktreeName - $retries = 5 - $retryDelay = 1 - $success = $false - - for ($i = 0; $i -lt $retries; $i++) { - try { - Set-Content -Path $worktreeGitFile -Value "gitdir: $hostGitPath" -NoNewline -Encoding utf8NoBOM -ErrorAction Stop - $success = $true - break - } - catch { - if ($i -lt ($retries - 1)) { - Write-Host " Waiting for file lock to release... (attempt $($i+1)/$retries)" -ForegroundColor Gray - Start-Sleep -Seconds $retryDelay - } - } - } - - if (-not $success) { - Write-Host "โš ๏ธ Could not reset .git file (still locked). You may need to:" -ForegroundColor Yellow - Write-Host " 1. Wait a moment for container to fully stop" -ForegroundColor Gray - Write-Host " 2. Manually edit: $worktreeGitFile" -ForegroundColor Gray - Write-Host " 3. Set content to: gitdir: $hostGitPath" -ForegroundColor Gray - } - else { - Write-Host " Set .git to: $hostGitPath" -ForegroundColor Gray - - # Reset gitdir in main repo's worktree metadata to host path - if (Test-Path $worktreeMetaGitdir) { - try { - Set-Content -Path $worktreeMetaGitdir -Value $worktreePath -NoNewline -Encoding utf8NoBOM -ErrorAction Stop - Write-Host " Set gitdir to: $worktreePath" -ForegroundColor Gray - } - catch { - Write-Host "โš ๏ธ Could not reset gitdir file (still locked)" -ForegroundColor Yellow - } - } - - Write-Host "โœ… Git paths restored for host" -ForegroundColor Green - } -} From 1989b468e40e300b0c12f9e2cd3c3a31a7034b7b Mon Sep 17 00:00:00 2001 From: James Casey Date: Wed, 4 Mar 2026 09:21:32 +0000 Subject: [PATCH 2/2] fix: improve robustness of hook checks, command parsing, and gitattributes merge - Replace $LASTEXITCODE with $? for PowerShell hook success check in Remove-CopilotWorktree - Use bash -c for devcontainer exec to preserve quoted arguments in New-CopilotWorktree - Wrap Get-DefaultBranch in try/finally to ensure Pop-Location runs on error - Use line-by-line comparison instead of regex for .gitattributes merge in Initialize-CopilotProject Amp-Thread-ID: https://ampcode.com/threads/T-019cb823-a1da-74f9-90bd-e2626b6a78bd Co-authored-by: Amp --- MultiCopilot/Public/Initialize-CopilotProject.ps1 | 5 +++-- MultiCopilot/Public/New-CopilotWorktree.ps1 | 10 +++++++--- MultiCopilot/Public/Remove-CopilotWorktree.ps1 | 6 +++--- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/MultiCopilot/Public/Initialize-CopilotProject.ps1 b/MultiCopilot/Public/Initialize-CopilotProject.ps1 index 0782649..19a36b8 100644 --- a/MultiCopilot/Public/Initialize-CopilotProject.ps1 +++ b/MultiCopilot/Public/Initialize-CopilotProject.ps1 @@ -96,12 +96,13 @@ function Initialize-CopilotProject { if ($PSCmdlet.ShouldProcess($destGitattributes, "Merge .gitattributes entries")) { $templateLines = Get-Content $templateGitattributes if (Test-Path $destGitattributes) { - $existingContent = Get-Content $destGitattributes -Raw + $existingLines = Get-Content $destGitattributes + $existingLinesTrimmed = $existingLines | ForEach-Object { $_.Trim() } $linesToAdd = @() foreach ($line in $templateLines) { $trimmed = $line.Trim() if ($trimmed -eq "" -or $trimmed.StartsWith("#")) { continue } - if ($existingContent -notmatch [regex]::Escape($trimmed)) { + if ($existingLinesTrimmed -notcontains $trimmed) { $linesToAdd += $line } } diff --git a/MultiCopilot/Public/New-CopilotWorktree.ps1 b/MultiCopilot/Public/New-CopilotWorktree.ps1 index 026be64..670cda3 100644 --- a/MultiCopilot/Public/New-CopilotWorktree.ps1 +++ b/MultiCopilot/Public/New-CopilotWorktree.ps1 @@ -143,8 +143,12 @@ function New-CopilotWorktree { # Set base branch if not specified if (-not $BaseBranch) { Push-Location $mainRepo - $BaseBranch = Get-DefaultBranch - Pop-Location + try { + $BaseBranch = Get-DefaultBranch + } + finally { + Pop-Location + } Write-Host "๐Ÿ“Œ Using default branch: $BaseBranch" -ForegroundColor Gray } @@ -499,7 +503,7 @@ function New-CopilotWorktree { # Connect to the container and run the command # Use try/finally to ensure git paths are reset when command exits try { - $cmdArgs = @("exec", "--workspace-folder", $worktreePath) + ($Command -split '\s+') + $cmdArgs = @("exec", "--workspace-folder", $worktreePath, "bash", "-c", $Command) & devcontainer @cmdArgs } finally { diff --git a/MultiCopilot/Public/Remove-CopilotWorktree.ps1 b/MultiCopilot/Public/Remove-CopilotWorktree.ps1 index 5b74a3e..19944e4 100644 --- a/MultiCopilot/Public/Remove-CopilotWorktree.ps1 +++ b/MultiCopilot/Public/Remove-CopilotWorktree.ps1 @@ -99,11 +99,11 @@ function Remove-CopilotWorktree { Write-Host "๐Ÿ”ง Running project cleanup hook..." -ForegroundColor Cyan try { & $hookScript -WorktreePath $worktreePath -Branch $Branch -MainRepo $mainRepo - if ($LASTEXITCODE -eq 0) { - Write-Host "โœ… Project cleanup hook completed" -ForegroundColor Green + if (-not $?) { + Write-Host "โš ๏ธ Project cleanup hook reported failure" -ForegroundColor Yellow } else { - Write-Host "โš ๏ธ Project cleanup hook returned non-zero exit code" -ForegroundColor Yellow + Write-Host "โœ… Project cleanup hook completed" -ForegroundColor Green } } catch {