mirror of
https://github.com/github/spec-kit.git
synced 2026-01-30 12:42:02 +00:00
The check_existing_branches (bash) and Get-NextBranchNumber (PowerShell) functions were only looking for branches/specs matching the SAME short name when determining the next feature number. This caused collisions where multiple features could be assigned the same number if they had different short names. For example, if feature 023-ci-optimization existed, creating a new feature with a different short name would incorrectly use 001 instead of 024. This fix changes both functions to: 1. Use get_highest_from_branches() / Get-HighestNumberFromBranches to find the highest number across ALL branches globally 2. Use get_highest_from_specs() / Get-HighestNumberFromSpecs to find the highest number across ALL spec directories globally 3. Return the maximum of both + 1 The helper functions already existed but were not being used. This fix properly utilizes them to ensure features are numbered sequentially regardless of their short names. Issue: Branch number collisions when creating features with different names Impact: Prevents multiple features from sharing the same number prefix
285 lines
9.3 KiB
PowerShell
285 lines
9.3 KiB
PowerShell
#!/usr/bin/env pwsh
|
|
# Create a new feature
|
|
[CmdletBinding()]
|
|
param(
|
|
[switch]$Json,
|
|
[string]$ShortName,
|
|
[int]$Number = 0,
|
|
[switch]$Help,
|
|
[Parameter(ValueFromRemainingArguments = $true)]
|
|
[string[]]$FeatureDescription
|
|
)
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
# Show help if requested
|
|
if ($Help) {
|
|
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] <feature description>"
|
|
Write-Host ""
|
|
Write-Host "Options:"
|
|
Write-Host " -Json Output in JSON format"
|
|
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 " -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'"
|
|
exit 0
|
|
}
|
|
|
|
# Check if feature description provided
|
|
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
|
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] <feature description>"
|
|
exit 1
|
|
}
|
|
|
|
$featureDesc = ($FeatureDescription -join ' ').Trim()
|
|
|
|
# Resolve repository root. Prefer git information when available, but fall back
|
|
# to searching for repository markers so the workflow still functions in repositories that
|
|
# were initialized with --no-git.
|
|
function Find-RepositoryRoot {
|
|
param(
|
|
[string]$StartDir,
|
|
[string[]]$Markers = @('.git', '.specify')
|
|
)
|
|
$current = Resolve-Path $StartDir
|
|
while ($true) {
|
|
foreach ($marker in $Markers) {
|
|
if (Test-Path (Join-Path $current $marker)) {
|
|
return $current
|
|
}
|
|
}
|
|
$parent = Split-Path $current -Parent
|
|
if ($parent -eq $current) {
|
|
# Reached filesystem root without finding markers
|
|
return $null
|
|
}
|
|
$current = $parent
|
|
}
|
|
}
|
|
|
|
function Get-HighestNumberFromSpecs {
|
|
param([string]$SpecsDir)
|
|
|
|
$highest = 0
|
|
if (Test-Path $SpecsDir) {
|
|
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
|
if ($_.Name -match '^(\d+)') {
|
|
$num = [int]$matches[1]
|
|
if ($num -gt $highest) { $highest = $num }
|
|
}
|
|
}
|
|
}
|
|
return $highest
|
|
}
|
|
|
|
function Get-HighestNumberFromBranches {
|
|
param()
|
|
|
|
$highest = 0
|
|
try {
|
|
$branches = git branch -a 2>$null
|
|
if ($LASTEXITCODE -eq 0) {
|
|
foreach ($branch in $branches) {
|
|
# Clean branch name: remove leading markers and remote prefixes
|
|
$cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
|
|
|
# Extract feature number if branch matches pattern ###-*
|
|
if ($cleanBranch -match '^(\d+)-') {
|
|
$num = [int]$matches[1]
|
|
if ($num -gt $highest) { $highest = $num }
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
# If git command fails, return 0
|
|
Write-Verbose "Could not check Git branches: $_"
|
|
}
|
|
return $highest
|
|
}
|
|
|
|
function Get-NextBranchNumber {
|
|
param(
|
|
[string]$ShortName,
|
|
[string]$SpecsDir
|
|
)
|
|
|
|
# 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
|
|
}
|
|
|
|
# Get highest number from ALL branches (not just matching short name)
|
|
$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 '-$', ''
|
|
}
|
|
$fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot)
|
|
if (-not $fallbackRoot) {
|
|
Write-Error "Error: Could not determine repository root. Please run this script from within the repository."
|
|
exit 1
|
|
}
|
|
|
|
try {
|
|
$repoRoot = git rev-parse --show-toplevel 2>$null
|
|
if ($LASTEXITCODE -eq 0) {
|
|
$hasGit = $true
|
|
} else {
|
|
throw "Git not available"
|
|
}
|
|
} catch {
|
|
$repoRoot = $fallbackRoot
|
|
$hasGit = $false
|
|
}
|
|
|
|
Set-Location $repoRoot
|
|
|
|
$specsDir = Join-Path $repoRoot 'specs'
|
|
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
|
|
}
|
|
|
|
# Determine branch number
|
|
if ($Number -eq 0) {
|
|
if ($hasGit) {
|
|
# Check existing branches on remotes
|
|
$Number = Get-NextBranchNumber -ShortName $branchSuffix -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: feature number (3) + hyphen (1) = 4 chars
|
|
$maxSuffixLength = $maxBranchLength - 4
|
|
|
|
# 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)"
|
|
}
|
|
|
|
if ($hasGit) {
|
|
try {
|
|
git checkout -b $branchName | Out-Null
|
|
} catch {
|
|
Write-Warning "Failed to create git branch: $branchName"
|
|
}
|
|
} else {
|
|
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
|
|
}
|
|
|
|
$featureDir = Join-Path $specsDir $branchName
|
|
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
|
|
|
$template = Join-Path $repoRoot '.specify/templates/spec-template.md'
|
|
$specFile = Join-Path $featureDir 'spec.md'
|
|
if (Test-Path $template) {
|
|
Copy-Item $template $specFile -Force
|
|
} else {
|
|
New-Item -ItemType File -Path $specFile | 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
|
|
}
|
|
$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"
|
|
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
|
}
|
|
|