| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403 |
- #!/usr/bin/env pwsh
- # Git extension: create-new-feature.ps1
- # Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout.
- # 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.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 without creating the branch"
- 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 "Environment variables:"
- Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
- Write-Host ""
- exit 0
- }
- 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()
- 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)
- [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
- }
- function Get-NextBranchNumber {
- param(
- [string]$SpecsDir,
- [switch]$SkipFetch
- )
- if ($SkipFetch) {
- $highestBranch = Get-HighestNumberFromBranches
- $highestRemote = Get-HighestNumberFromRemoteRefs
- $highestBranch = [Math]::Max($highestBranch, $highestRemote)
- } else {
- try {
- git fetch --all --prune 2>$null | Out-Null
- } catch { }
- $highestBranch = Get-HighestNumberFromBranches
- }
- $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."
- }
- # Resolve repository root
- 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'
- 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 -match "\b$($word.ToUpper())\b") {
- $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 = [System.Text.Encoding]::UTF8.GetByteCount($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."
- }
- # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
- # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern
- if ($branchName -match '^(\d{8}-\d{6})-') {
- $featureNum = $matches[1]
- } elseif ($branchName -match '^(\d+)-') {
- $featureNum = $matches[1]
- } else {
- $featureNum = $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 = "$featureNum-$branchSuffix"
- } else {
- if ($Number -eq 0) {
- if ($DryRun -and $hasGit) {
- $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
- } elseif ($DryRun) {
- $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
- } elseif ($hasGit) {
- $Number = Get-NextBranchNumber -SpecsDir $specsDir
- } else {
- $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
- }
- }
- $featureNum = ('{0:000}' -f $Number)
- $branchName = "$featureNum-$branchSuffix"
- }
- }
- $maxBranchLength = 244
- if ($branchName.Length -gt $maxBranchLength) {
- $prefixLength = $featureNum.Length + 1
- $maxSuffixLength = $maxBranchLength - $prefixLength
- $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
- $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 (-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
- 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 "FEATURE_NUM: $featureNum"
- Write-Output "HAS_GIT: $hasGit"
- if (-not $DryRun) {
- Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
- }
- }
|