mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
* fix(scripts): warn when spec template is missing in create-new-feature.ps1 (parity with bash) create-new-feature.sh prints 'Warning: Spec template not found; created empty spec file' to stderr when no spec template resolves, then touches an empty spec. The PowerShell twin created the empty file silently with no warning, so on Windows a missing/broken template tree gave no signal. Emit the same warning on stderr (keeps stdout/JSON pure), matching the bash wording and stream. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: assert create-new-feature.ps1 warns on missing spec template Regression test for the bash/PowerShell parity fix: with no resolvable spec template, the PowerShell script must emit 'Spec template not found' on stderr (matching bash) while keeping stdout parseable JSON and still creating the empty spec file. Gated on pwsh; decodes stdout/stderr as UTF-8. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
249 lines
10 KiB
PowerShell
249 lines
10 KiB
PowerShell
#!/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 -cmatch "\b$($word.ToUpper())\b") {
|
|
# Keep short words only if they appear as uppercase in original (likely
|
|
# acronyms). Use -cmatch so the comparison is case-sensitive, matching the
|
|
# bash script's case-sensitive grep; -match would be case-insensitive and
|
|
# would keep every short word.
|
|
$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. Use ContainsKey (not
|
|
# `-ne 0`) so an explicit `-Number 0` is also detected, matching the bash twin's
|
|
# `[ -n "$BRANCH_NUMBER" ]` check.
|
|
if ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) {
|
|
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. Auto-detect only
|
|
# when -Number was not supplied; an explicit value (including 0) is honored,
|
|
# matching the bash twin's `[ -z "$BRANCH_NUMBER" ]` check.
|
|
if (-not $PSBoundParameters.ContainsKey('Number')) {
|
|
$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 {
|
|
# Match the bash twin (create-new-feature.sh): warn on stderr that no
|
|
# spec template was found before creating an empty spec file, so the
|
|
# missing-template signal is not silently swallowed on Windows.
|
|
[Console]::Error.WriteLine("Warning: Spec template not found; created empty spec file")
|
|
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"
|
|
}
|
|
}
|