#!/usr/bin/env pwsh # Git extension: create-new-feature.ps1 # Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout. # Sources common.ps1 from the project's installed scripts, falling back to # git-common.ps1 for minimal git helpers. [CmdletBinding()] param( [switch]$Json, [switch]$AllowExistingBranch, [switch]$DryRun, [string]$ShortName, [Parameter()] [long]$Number = 0, [switch]$Timestamp, [switch]$Help, [Parameter(Position = 0, ValueFromRemainingArguments = $true)] [string[]]$FeatureDescription ) $ErrorActionPreference = 'Stop' if ($Help) { Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " Write-Host "" Write-Host "Options:" Write-Host " -Json Output in JSON format" Write-Host " -DryRun Compute branch name without creating the branch" Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing" Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" Write-Host " -Number N Specify branch number manually (overrides auto-detection)" Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" Write-Host " -Help Show this help message" Write-Host "" Write-Host "Environment variables:" Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" Write-Host "" exit 0 } if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " exit 1 } $featureDesc = ($FeatureDescription -join ' ').Trim() if ([string]::IsNullOrWhiteSpace($featureDesc)) { Write-Error "Error: Feature description cannot be empty or contain only whitespace" exit 1 } function Get-HighestNumberFromSpecs { param([string]$SpecsDir) [long]$highest = 0 if (Test-Path $SpecsDir) { Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object { if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') { [long]$num = 0 if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { $highest = $num } } } } return $highest } function Get-HighestNumberFromNames { param([string[]]$Names) [long]$highest = 0 foreach ($name in $Names) { if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') { [long]$num = 0 if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { $highest = $num } } } return $highest } function Get-HighestNumberFromBranches { param() try { $branches = git branch -a 2>$null if ($LASTEXITCODE -eq 0 -and $branches) { $cleanNames = $branches | ForEach-Object { $_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' } return Get-HighestNumberFromNames -Names $cleanNames } } catch { Write-Verbose "Could not check Git branches: $_" } return 0 } function Get-HighestNumberFromRemoteRefs { [long]$highest = 0 try { $remotes = git remote 2>$null if ($remotes) { foreach ($remote in $remotes) { $env:GIT_TERMINAL_PROMPT = '0' $refs = git ls-remote --heads $remote 2>$null $env:GIT_TERMINAL_PROMPT = $null if ($LASTEXITCODE -eq 0 -and $refs) { $refNames = $refs | ForEach-Object { if ($_ -match 'refs/heads/(.+)$') { $matches[1] } } | Where-Object { $_ } $remoteHighest = Get-HighestNumberFromNames -Names $refNames if ($remoteHighest -gt $highest) { $highest = $remoteHighest } } } } } catch { Write-Verbose "Could not query remote refs: $_" } return $highest } function Get-NextBranchNumber { param( [string]$SpecsDir, [switch]$SkipFetch ) if ($SkipFetch) { $highestBranch = Get-HighestNumberFromBranches $highestRemote = Get-HighestNumberFromRemoteRefs $highestBranch = [Math]::Max($highestBranch, $highestRemote) } else { try { git fetch --all --prune 2>$null | Out-Null } catch { } $highestBranch = Get-HighestNumberFromBranches } $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir $maxNum = [Math]::Max($highestBranch, $highestSpec) return $maxNum + 1 } function ConvertTo-CleanBranchName { param([string]$Name) return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' } # --------------------------------------------------------------------------- # Source common.ps1 from the project's installed scripts. # Search locations in priority order: # 1. .specify/scripts/powershell/common.ps1 under the project root # 2. scripts/powershell/common.ps1 under the project root (source checkout) # 3. git-common.ps1 next to this script (minimal fallback) # --------------------------------------------------------------------------- function Find-ProjectRoot { param([string]$StartDir) $current = Resolve-Path $StartDir while ($true) { foreach ($marker in @('.specify', '.git')) { if (Test-Path (Join-Path $current $marker)) { return $current } } $parent = Split-Path $current -Parent if ($parent -eq $current) { return $null } $current = $parent } } $projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot $commonLoaded = $false if ($projectRoot) { $candidates = @( (Join-Path $projectRoot ".specify/scripts/powershell/common.ps1"), (Join-Path $projectRoot "scripts/powershell/common.ps1") ) foreach ($candidate in $candidates) { if (Test-Path $candidate) { . $candidate $commonLoaded = $true break } } } if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) { . "$PSScriptRoot/git-common.ps1" $commonLoaded = $true } if (-not $commonLoaded) { throw "Unable to locate common script file. Please ensure the Specify core scripts are installed." } # Resolve repository root if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) { $repoRoot = Get-RepoRoot } elseif ($projectRoot) { $repoRoot = $projectRoot } else { throw "Could not determine repository root." } # Check if git is available if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) { # Call without parameters for compatibility with core common.ps1 (no -RepoRoot param) # and git-common.ps1 (has -RepoRoot param with default). $hasGit = Test-HasGit } else { try { git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null $hasGit = ($LASTEXITCODE -eq 0) } catch { $hasGit = $false } } Set-Location $repoRoot $specsDir = Join-Path $repoRoot 'specs' function Get-BranchName { param([string]$Description) $stopWords = @( 'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall', 'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their', 'want', 'need', 'add', 'get', 'set' ) $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' ' $words = $cleanName -split '\s+' | Where-Object { $_ } $meaningfulWords = @() foreach ($word in $words) { if ($stopWords -contains $word) { continue } if ($word.Length -ge 3) { $meaningfulWords += $word } elseif ($Description -match "\b$($word.ToUpper())\b") { $meaningfulWords += $word } } if ($meaningfulWords.Count -gt 0) { $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 } $result = ($meaningfulWords | Select-Object -First $maxWords) -join '-' return $result } else { $result = ConvertTo-CleanBranchName -Name $Description $fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3 return [string]::Join('-', $fallbackWords) } } # Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) if ($env:GIT_BRANCH_NAME) { $branchName = $env:GIT_BRANCH_NAME # Check 244-byte limit (UTF-8) for override names $branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName) if ($branchNameUtf8ByteCount -gt 244) { throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name." } # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern if ($branchName -match '^(\d{8}-\d{6})-') { $featureNum = $matches[1] } elseif ($branchName -match '^(\d+)-') { $featureNum = $matches[1] } else { $featureNum = $branchName } } else { if ($ShortName) { $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName } else { $branchSuffix = Get-BranchName -Description $featureDesc } if ($Timestamp -and $Number -ne 0) { Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" $Number = 0 } if ($Timestamp) { $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' $branchName = "$featureNum-$branchSuffix" } else { if ($Number -eq 0) { if ($DryRun -and $hasGit) { $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch } elseif ($DryRun) { $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 } elseif ($hasGit) { $Number = Get-NextBranchNumber -SpecsDir $specsDir } else { $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 } } $featureNum = ('{0:000}' -f $Number) $branchName = "$featureNum-$branchSuffix" } } $maxBranchLength = 244 if ($branchName.Length -gt $maxBranchLength) { $prefixLength = $featureNum.Length + 1 $maxSuffixLength = $maxBranchLength - $prefixLength $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) $truncatedSuffix = $truncatedSuffix -replace '-$', '' $originalBranchName = $branchName $branchName = "$featureNum-$truncatedSuffix" Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" } if (-not $DryRun) { if ($hasGit) { $branchCreated = $false $branchCreateError = '' try { $branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String if ($LASTEXITCODE -eq 0) { $branchCreated = $true } } catch { $branchCreateError = $_.Exception.Message } if (-not $branchCreated) { $currentBranch = '' try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {} $existingBranch = git branch --list $branchName 2>$null if ($existingBranch) { if ($AllowExistingBranch) { if ($currentBranch -eq $branchName) { # Already on the target branch } else { $switchBranchError = git checkout -q $branchName 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { if ($switchBranchError) { Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())" } else { Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." } exit 1 } } } elseif ($Timestamp) { Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName." exit 1 } else { Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." exit 1 } } else { if ($branchCreateError) { Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())" } else { Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." } exit 1 } } } else { if ($Json) { [Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName") } else { Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" } } $env:SPECIFY_FEATURE = $branchName } if ($Json) { $obj = [PSCustomObject]@{ BRANCH_NAME = $branchName FEATURE_NUM = $featureNum HAS_GIT = $hasGit } if ($DryRun) { $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true } $obj | ConvertTo-Json -Compress } else { Write-Output "BRANCH_NAME: $branchName" Write-Output "FEATURE_NUM: $featureNum" Write-Output "HAS_GIT: $hasGit" if (-not $DryRun) { Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" } }