Spaces:
Sleeping
Sleeping
| #!/usr/bin/env pwsh | |
| # Create a new feature | |
| [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' | |
| # Show help if requested | |
| if ($Help) { | |
| Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>" | |
| Write-Host "" | |
| Write-Host "Options:" | |
| Write-Host " -Json Output in JSON format" | |
| Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files" | |
| Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing" | |
| Write-Host " -ShortName <name> 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 "Examples:" | |
| Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'" | |
| Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'" | |
| Write-Host " ./create-new-feature.ps1 -Timestamp -ShortName 'user-auth' 'Add user authentication'" | |
| exit 0 | |
| } | |
| # Check if feature description provided | |
| if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { | |
| Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>" | |
| exit 1 | |
| } | |
| $featureDesc = ($FeatureDescription -join ' ').Trim() | |
| # Validate description is not empty after trimming (e.g., user passed only whitespace) | |
| 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 { | |
| # Match sequential prefixes (>=3 digits), but skip timestamp dirs. | |
| 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 | |
| } | |
| # Extract the highest sequential feature number from a list of branch/ref names. | |
| # Shared by Get-HighestNumberFromBranches and Get-HighestNumberFromRemoteRefs. | |
| 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 | |
| } | |
| # Return next available branch number. When SkipFetch is true, queries remotes | |
| # via ls-remote (read-only) instead of fetching. | |
| function Get-NextBranchNumber { | |
| param( | |
| [string]$SpecsDir, | |
| [switch]$SkipFetch | |
| ) | |
| if ($SkipFetch) { | |
| # Side-effect-free: query remotes via ls-remote | |
| $highestBranch = Get-HighestNumberFromBranches | |
| $highestRemote = Get-HighestNumberFromRemoteRefs | |
| $highestBranch = [Math]::Max($highestBranch, $highestRemote) | |
| } else { | |
| # Fetch all remotes to get latest branch info (suppress errors if no remotes) | |
| try { | |
| git fetch --all --prune 2>$null | Out-Null | |
| } catch { | |
| # Ignore fetch errors | |
| } | |
| $highestBranch = Get-HighestNumberFromBranches | |
| } | |
| # Get highest number from ALL specs (not just matching short name) | |
| $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir | |
| # Take the maximum of both | |
| $maxNum = [Math]::Max($highestBranch, $highestSpec) | |
| # Return next number | |
| return $maxNum + 1 | |
| } | |
| function ConvertTo-CleanBranchName { | |
| param([string]$Name) | |
| return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' | |
| } | |
| # Load common functions (includes Get-RepoRoot, Test-HasGit, Resolve-Template) | |
| . "$PSScriptRoot/common.ps1" | |
| # Use common.ps1 functions which prioritize .specify over git | |
| $repoRoot = Get-RepoRoot | |
| # Check if git is available at this repo root (not a parent) | |
| $hasGit = Test-HasGit | |
| Set-Location $repoRoot | |
| $specsDir = Join-Path $repoRoot 'specs' | |
| if (-not $DryRun) { | |
| New-Item -ItemType Directory -Path $specsDir -Force | Out-Null | |
| } | |
| # Function to generate branch name with stop word filtering and length filtering | |
| function Get-BranchName { | |
| param([string]$Description) | |
| # Common stop words to filter out | |
| $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' | |
| ) | |
| # Convert to lowercase and extract words (alphanumeric only) | |
| $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' ' | |
| $words = $cleanName -split '\s+' | Where-Object { $_ } | |
| # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) | |
| $meaningfulWords = @() | |
| foreach ($word in $words) { | |
| # Skip stop words | |
| if ($stopWords -contains $word) { continue } | |
| # Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms) | |
| if ($word.Length -ge 3) { | |
| $meaningfulWords += $word | |
| } elseif ($Description -match "\b$($word.ToUpper())\b") { | |
| # Keep short words if they appear as uppercase in original (likely acronyms) | |
| $meaningfulWords += $word | |
| } | |
| } | |
| # If we have meaningful words, use first 3-4 of them | |
| if ($meaningfulWords.Count -gt 0) { | |
| $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 } | |
| $result = ($meaningfulWords | Select-Object -First $maxWords) -join '-' | |
| return $result | |
| } else { | |
| # Fallback to original logic if no meaningful words found | |
| $result = ConvertTo-CleanBranchName -Name $Description | |
| $fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3 | |
| return [string]::Join('-', $fallbackWords) | |
| } | |
| } | |
| # Generate branch name | |
| if ($ShortName) { | |
| # Use provided short name, just clean it up | |
| $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName | |
| } else { | |
| # Generate from description with smart filtering | |
| $branchSuffix = Get-BranchName -Description $featureDesc | |
| } | |
| # Warn if -Number and -Timestamp are both specified | |
| if ($Timestamp -and $Number -ne 0) { | |
| Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" | |
| $Number = 0 | |
| } | |
| # Determine branch prefix | |
| if ($Timestamp) { | |
| $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' | |
| $branchName = "$featureNum-$branchSuffix" | |
| } else { | |
| # Determine branch number | |
| if ($Number -eq 0) { | |
| if ($DryRun -and $hasGit) { | |
| # Dry-run: query remotes via ls-remote (side-effect-free, no fetch) | |
| $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch | |
| } elseif ($DryRun) { | |
| # Dry-run without git: local spec dirs only | |
| $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 | |
| } elseif ($hasGit) { | |
| # Check existing branches on remotes | |
| $Number = Get-NextBranchNumber -SpecsDir $specsDir | |
| } else { | |
| # Fall back to local directory check | |
| $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 | |
| } | |
| } | |
| $featureNum = ('{0:000}' -f $Number) | |
| $branchName = "$featureNum-$branchSuffix" | |
| } | |
| # GitHub enforces a 244-byte limit on branch names | |
| # Validate and truncate if necessary | |
| $maxBranchLength = 244 | |
| if ($branchName.Length -gt $maxBranchLength) { | |
| # Calculate how much we need to trim from suffix | |
| # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 | |
| $prefixLength = $featureNum.Length + 1 | |
| $maxSuffixLength = $maxBranchLength - $prefixLength | |
| # Truncate suffix | |
| $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) | |
| # Remove trailing hyphen if truncation created one | |
| $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)" | |
| } | |
| $featureDir = Join-Path $specsDir $branchName | |
| $specFile = Join-Path $featureDir 'spec.md' | |
| 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 {} | |
| # Check if branch already exists | |
| $existingBranch = git branch --list $branchName 2>$null | |
| if ($existingBranch) { | |
| if ($AllowExistingBranch) { | |
| # If we're already on the branch, continue without another checkout. | |
| if ($currentBranch -eq $branchName) { | |
| # Already on the target branch — nothing to do | |
| } else { | |
| # Otherwise switch to the existing branch instead of failing. | |
| $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 { | |
| Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" | |
| } | |
| New-Item -ItemType Directory -Path $featureDir -Force | Out-Null | |
| if (-not (Test-Path -PathType Leaf $specFile)) { | |
| $template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot | |
| if ($template -and (Test-Path $template)) { | |
| # Read the template content and write it to the spec file with UTF-8 encoding without BOM | |
| $content = [System.IO.File]::ReadAllText($template) | |
| $utf8NoBom = New-Object System.Text.UTF8Encoding($false) | |
| [System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom) | |
| } else { | |
| New-Item -ItemType File -Path $specFile -Force | Out-Null | |
| } | |
| } | |
| # Set the SPECIFY_FEATURE environment variable for the current session | |
| $env:SPECIFY_FEATURE = $branchName | |
| } | |
| if ($Json) { | |
| $obj = [PSCustomObject]@{ | |
| BRANCH_NAME = $branchName | |
| SPEC_FILE = $specFile | |
| 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 "SPEC_FILE: $specFile" | |
| Write-Output "FEATURE_NUM: $featureNum" | |
| Write-Output "HAS_GIT: $hasGit" | |
| if (-not $DryRun) { | |
| Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" | |
| } | |
| } | |