| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237 |
- #!/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 feature name and paths without creating directories or files"
- Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
- Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the feature"
- 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
- }
- function ConvertTo-CleanBranchName {
- param([string]$Name)
- return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
- }
- # Load common functions (includes Get-RepoRoot and Resolve-Template)
- . "$PSScriptRoot/common.ps1"
- # Use common.ps1 functions which prioritize .specify
- $repoRoot = Get-RepoRoot
- 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 from existing feature directories
- if ($Number -eq 0) {
- $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 ((Test-Path -LiteralPath $featureDir -PathType Container) -and -not $AllowExistingBranch) {
- if ($Timestamp) {
- Write-Error "Error: Feature directory '$featureDir' already exists. Rerun to get a new timestamp or use a different -ShortName."
- } else {
- Write-Error "Error: Feature directory '$featureDir' already exists. Please use a different feature name or specify a different number with -Number."
- }
- exit 1
- }
- 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
- }
- }
- # Persist to .specify/feature.json so downstream commands can find the feature
- Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $featureDir
- # Set environment variables for the current session
- $env:SPECIFY_FEATURE = $branchName
- $env:SPECIFY_FEATURE_DIRECTORY = $featureDir
- }
- if ($Json) {
- $obj = [PSCustomObject]@{
- BRANCH_NAME = $branchName
- SPEC_FILE = $specFile
- FEATURE_NUM = $featureNum
- }
- 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"
- if (-not $DryRun) {
- Write-Output "SPECIFY_FEATURE set to: $branchName"
- Write-Output "SPECIFY_FEATURE_DIRECTORY set to: $featureDir"
- }
- }
|