#!/usr/bin/env pwsh # Git extension: create-new-feature-branch.ps1 # Creates a git feature branch only. The feature directory and spec file # are created by the core create-new-feature.ps1 script. # 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-branch.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 "" Write-Host "Configuration:" Write-Host " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}" Write-Host " branch_prefix Optional shorthand namespace expanded before {number}-{slug}" Write-Host "" exit 0 } if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { Write-Error "Usage: ./create-new-feature-branch.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, [string]$ScopePrefix = '' ) [long]$highest = 0 foreach ($name in $Names) { if ($ScopePrefix -and -not $name.StartsWith($ScopePrefix, [System.StringComparison]::Ordinal)) { continue } if ($ScopePrefix) { $name = $name.Substring($ScopePrefix.Length) } $name = ($name -split '/')[-1] $hasTimestampPrefix = $name -match '^\d{8}-\d{6}-' $hasMalformedTimestamp = ($name -match '^\d{7}-\d{6}-') -or ($name -match '^(?:\d{7}|\d{8})-\d{6}$') if ($name -match '^(\d{3,})-' -and -not $hasTimestampPrefix -and -not $hasMalformedTimestamp) { [long]$num = 0 if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { $highest = $num } } } return $highest } function Get-HighestNumberFromBranches { param([string]$ScopePrefix = '') 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 -ScopePrefix $ScopePrefix } } catch { Write-Verbose "Could not check Git branches: $_" } return 0 } function Get-HighestNumberFromRemoteRefs { param([string]$ScopePrefix = '') [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 -ScopePrefix $ScopePrefix if ($remoteHighest -gt $highest) { $highest = $remoteHighest } } } } } catch { Write-Verbose "Could not query remote refs: $_" } return $highest } function Get-NextBranchNumber { param( [string]$SpecsDir, [switch]$SkipFetch, [string]$ScopePrefix = '' ) if ($SkipFetch) { $highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix $highestRemote = Get-HighestNumberFromRemoteRefs -ScopePrefix $ScopePrefix $highestBranch = [Math]::Max($highestBranch, $highestRemote) } else { try { git fetch --all --prune 2>$null | Out-Null } catch { } $highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix } $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." } # SPECIFY_INIT_DIR is resolved (and validated) by the core resolver. If only the # minimal git-common.ps1 was loaded, or an older core common.ps1 without the # resolver was loaded, refuse rather than silently falling back to the wrong root. if ($env:SPECIFY_INIT_DIR -and -not (Get-Command Resolve-SpecifyInitDir -CommandType Function -ErrorAction SilentlyContinue)) { throw "SPECIFY_INIT_DIR requires updated Spec Kit core scripts (common.ps1 with Resolve-SpecifyInitDir), which were not found." } # Resolve repository root. When the core scripts are present, Get-RepoRoot # honors SPECIFY_INIT_DIR (the explicit project override for non-interactive / # CI use) and hard-fails on an invalid value with no silent fallback. 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' $configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml" function Read-GitConfigValue { param([string]$Key) if (-not (Test-Path -LiteralPath $configFile -PathType Leaf)) { return '' } $escapedKey = [regex]::Escape($Key) foreach ($line in Get-Content -LiteralPath $configFile) { if ($line -match "^\s*$escapedKey\s*:\s*(.*)$") { $val = ($matches[1] -replace '\s+#.*$', '').Trim() $val = $val -replace '^["'']', '' -replace '["'']$', '' return $val } } return '' } function ConvertTo-BranchToken { param( [string]$Value, [string]$Fallback ) $cleaned = ConvertTo-CleanBranchName -Name $Value if ($cleaned) { return $cleaned } return $Fallback } function Get-GitAuthorToken { $author = '' if (Get-Command git -ErrorAction SilentlyContinue) { try { $author = (git config user.name 2>$null | Out-String).Trim() } catch {} if (-not $author) { try { $email = (git config user.email 2>$null | Out-String).Trim() if ($email) { $author = ($email -split '@')[0] } } catch {} } } if (-not $author) { $author = if ($env:USER) { $env:USER } elseif ($env:USERNAME) { $env:USERNAME } else { 'unknown' } } return ConvertTo-BranchToken -Value $author -Fallback 'unknown' } function Get-AppToken { return ConvertTo-BranchToken -Value (Split-Path $repoRoot -Leaf) -Fallback 'app' } function Resolve-BranchTemplate { $template = Read-GitConfigValue -Key 'branch_template' if ($template) { return $template } $prefix = Read-GitConfigValue -Key 'branch_prefix' if (-not $prefix) { return '' } if ($prefix.EndsWith('/')) { return "${prefix}{number}-{slug}" } return "$prefix/{number}-{slug}" } function Expand-BranchTemplate { param( [string]$Template, [string]$FeatureNum, [string]$BranchSuffix ) $rendered = $Template.Replace('{author}', $authorToken) $rendered = $rendered.Replace('{app}', $appToken) $rendered = $rendered.Replace('{number}', $FeatureNum) $rendered = $rendered.Replace('{slug}', $BranchSuffix) return $rendered } function Assert-BranchTemplateValid { param([string]$Template) if ($Template -and -not $Template.Contains('{number}')) { throw "branch_template must include the {number} token so generated branches remain valid feature branches." } if ($Template) { $numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal) $slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal) if ($slugIndex -ge 0 -and $slugIndex -lt $numberIndex) { throw "branch_template must not place {slug} before {number}; use {slug} only in the final feature segment." } $featureSegment = ($Template -split '/')[-1] if (-not $featureSegment.StartsWith('{number}-', [System.StringComparison]::Ordinal)) { throw "branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches." } } } function New-BranchName { param( [string]$FeatureNum, [string]$BranchSuffix ) if ($branchTemplate) { return Expand-BranchTemplate -Template $branchTemplate -FeatureNum $FeatureNum -BranchSuffix $BranchSuffix } return "$FeatureNum-$BranchSuffix" } function Get-BranchScopePrefix { param( [string]$Template, [string]$BranchSuffix ) if (-not $Template) { return '' } $numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal) $slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal) $indexes = @($numberIndex, $slugIndex) | Where-Object { $_ -ge 0 } | Sort-Object if (-not $indexes) { return '' } $prefix = $Template.Substring(0, $indexes[0]) return Expand-BranchTemplate -Template $prefix -FeatureNum '' -BranchSuffix $BranchSuffix } function Get-FeatureNumberFromBranchName { param([string]$BranchName) $featureSegment = ($BranchName -split '/')[-1] if ($featureSegment -match '^(\d{8}-\d{6})-') { return $matches[1] } if ($featureSegment -match '^(\d+)-') { return $matches[1] } return $BranchName } function Get-Utf8ByteCount { param([string]$Value) return [System.Text.Encoding]::UTF8.GetByteCount($Value) } $authorToken = Get-GitAuthorToken $appToken = Get-AppToken $branchTemplate = Resolve-BranchTemplate Assert-BranchTemplateValid -Template $branchTemplate 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 -cmatch "\b$($word.ToUpper())\b") { # Case-sensitive (-cmatch) to mirror the bash twin's case-sensitive # whole-word acronym match: keep a short word only when its UPPERCASE # form appears in the original (an acronym). -match is case-insensitive # and would keep every short word. $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 = Get-Utf8ByteCount -Value $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." } $featureNum = Get-FeatureNumberFromBranchName -BranchName $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 = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix } else { $branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix if ($Number -eq 0) { if ($DryRun -and $hasGit) { $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix } elseif ($DryRun) { $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 } elseif ($hasGit) { $Number = Get-NextBranchNumber -SpecsDir $specsDir -ScopePrefix $branchScopePrefix } else { $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 } } $featureNum = ('{0:000}' -f $Number) $branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix } } $maxBranchLength = 244 if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) { $originalBranchName = $branchName $truncatedSuffix = $branchSuffix while ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength -and $truncatedSuffix.Length -gt 0) { $truncatedSuffix = $truncatedSuffix.Substring(0, $truncatedSuffix.Length - 1) -replace '-$', '' $branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $truncatedSuffix } if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) { throw "Branch template prefix exceeds GitHub's 244-byte branch name limit." } Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" Write-Warning "[specify] Original: $originalBranchName ($(Get-Utf8ByteCount -Value $originalBranchName) bytes)" Write-Warning "[specify] Truncated to: $branchName ($(Get-Utf8ByteCount -Value $branchName) 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 } # $hasGit is computed for branch-creation logic only; it is intentionally not # emitted so this output contract matches the bash twin: BRANCH_NAME and # FEATURE_NUM, plus DRY_RUN (added just below) on dry runs. 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" if (-not $DryRun) { Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" } }