403 lines
14 KiB
PowerShell
403 lines
14 KiB
PowerShell
#!/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"
|
|
}
|
|
}
|