create-new-feature.ps1 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. #!/usr/bin/env pwsh
  2. # Create a new feature
  3. [CmdletBinding()]
  4. param(
  5. [switch]$Json,
  6. [switch]$AllowExistingBranch,
  7. [switch]$DryRun,
  8. [string]$ShortName,
  9. [Parameter()]
  10. [long]$Number = 0,
  11. [switch]$Timestamp,
  12. [switch]$Help,
  13. [Parameter(Position = 0, ValueFromRemainingArguments = $true)]
  14. [string[]]$FeatureDescription
  15. )
  16. $ErrorActionPreference = 'Stop'
  17. # Show help if requested
  18. if ($Help) {
  19. Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
  20. Write-Host ""
  21. Write-Host "Options:"
  22. Write-Host " -Json Output in JSON format"
  23. Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files"
  24. Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
  25. Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
  26. Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
  27. Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
  28. Write-Host " -Help Show this help message"
  29. Write-Host ""
  30. Write-Host "Examples:"
  31. Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'"
  32. Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'"
  33. Write-Host " ./create-new-feature.ps1 -Timestamp -ShortName 'user-auth' 'Add user authentication'"
  34. exit 0
  35. }
  36. # Check if feature description provided
  37. if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
  38. Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
  39. exit 1
  40. }
  41. $featureDesc = ($FeatureDescription -join ' ').Trim()
  42. # Validate description is not empty after trimming (e.g., user passed only whitespace)
  43. if ([string]::IsNullOrWhiteSpace($featureDesc)) {
  44. Write-Error "Error: Feature description cannot be empty or contain only whitespace"
  45. exit 1
  46. }
  47. function Get-HighestNumberFromSpecs {
  48. param([string]$SpecsDir)
  49. [long]$highest = 0
  50. if (Test-Path $SpecsDir) {
  51. Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
  52. # Match sequential prefixes (>=3 digits), but skip timestamp dirs.
  53. if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
  54. [long]$num = 0
  55. if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
  56. $highest = $num
  57. }
  58. }
  59. }
  60. }
  61. return $highest
  62. }
  63. # Extract the highest sequential feature number from a list of branch/ref names.
  64. # Shared by Get-HighestNumberFromBranches and Get-HighestNumberFromRemoteRefs.
  65. function Get-HighestNumberFromNames {
  66. param([string[]]$Names)
  67. [long]$highest = 0
  68. foreach ($name in $Names) {
  69. if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
  70. [long]$num = 0
  71. if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
  72. $highest = $num
  73. }
  74. }
  75. }
  76. return $highest
  77. }
  78. function Get-HighestNumberFromBranches {
  79. param()
  80. try {
  81. $branches = git branch -a 2>$null
  82. if ($LASTEXITCODE -eq 0 -and $branches) {
  83. $cleanNames = $branches | ForEach-Object {
  84. $_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
  85. }
  86. return Get-HighestNumberFromNames -Names $cleanNames
  87. }
  88. } catch {
  89. Write-Verbose "Could not check Git branches: $_"
  90. }
  91. return 0
  92. }
  93. function Get-HighestNumberFromRemoteRefs {
  94. [long]$highest = 0
  95. try {
  96. $remotes = git remote 2>$null
  97. if ($remotes) {
  98. foreach ($remote in $remotes) {
  99. $env:GIT_TERMINAL_PROMPT = '0'
  100. $refs = git ls-remote --heads $remote 2>$null
  101. $env:GIT_TERMINAL_PROMPT = $null
  102. if ($LASTEXITCODE -eq 0 -and $refs) {
  103. $refNames = $refs | ForEach-Object {
  104. if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
  105. } | Where-Object { $_ }
  106. $remoteHighest = Get-HighestNumberFromNames -Names $refNames
  107. if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
  108. }
  109. }
  110. }
  111. } catch {
  112. Write-Verbose "Could not query remote refs: $_"
  113. }
  114. return $highest
  115. }
  116. # Return next available branch number. When SkipFetch is true, queries remotes
  117. # via ls-remote (read-only) instead of fetching.
  118. function Get-NextBranchNumber {
  119. param(
  120. [string]$SpecsDir,
  121. [switch]$SkipFetch
  122. )
  123. if ($SkipFetch) {
  124. # Side-effect-free: query remotes via ls-remote
  125. $highestBranch = Get-HighestNumberFromBranches
  126. $highestRemote = Get-HighestNumberFromRemoteRefs
  127. $highestBranch = [Math]::Max($highestBranch, $highestRemote)
  128. } else {
  129. # Fetch all remotes to get latest branch info (suppress errors if no remotes)
  130. try {
  131. git fetch --all --prune 2>$null | Out-Null
  132. } catch {
  133. # Ignore fetch errors
  134. }
  135. $highestBranch = Get-HighestNumberFromBranches
  136. }
  137. # Get highest number from ALL specs (not just matching short name)
  138. $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
  139. # Take the maximum of both
  140. $maxNum = [Math]::Max($highestBranch, $highestSpec)
  141. # Return next number
  142. return $maxNum + 1
  143. }
  144. function ConvertTo-CleanBranchName {
  145. param([string]$Name)
  146. return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
  147. }
  148. # Load common functions (includes Get-RepoRoot, Test-HasGit, Resolve-Template)
  149. . "$PSScriptRoot/common.ps1"
  150. # Use common.ps1 functions which prioritize .specify over git
  151. $repoRoot = Get-RepoRoot
  152. # Check if git is available at this repo root (not a parent)
  153. $hasGit = Test-HasGit
  154. Set-Location $repoRoot
  155. $specsDir = Join-Path $repoRoot 'specs'
  156. if (-not $DryRun) {
  157. New-Item -ItemType Directory -Path $specsDir -Force | Out-Null
  158. }
  159. # Function to generate branch name with stop word filtering and length filtering
  160. function Get-BranchName {
  161. param([string]$Description)
  162. # Common stop words to filter out
  163. $stopWords = @(
  164. 'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
  165. 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
  166. 'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
  167. 'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
  168. 'want', 'need', 'add', 'get', 'set'
  169. )
  170. # Convert to lowercase and extract words (alphanumeric only)
  171. $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
  172. $words = $cleanName -split '\s+' | Where-Object { $_ }
  173. # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
  174. $meaningfulWords = @()
  175. foreach ($word in $words) {
  176. # Skip stop words
  177. if ($stopWords -contains $word) { continue }
  178. # Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms)
  179. if ($word.Length -ge 3) {
  180. $meaningfulWords += $word
  181. } elseif ($Description -match "\b$($word.ToUpper())\b") {
  182. # Keep short words if they appear as uppercase in original (likely acronyms)
  183. $meaningfulWords += $word
  184. }
  185. }
  186. # If we have meaningful words, use first 3-4 of them
  187. if ($meaningfulWords.Count -gt 0) {
  188. $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
  189. $result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
  190. return $result
  191. } else {
  192. # Fallback to original logic if no meaningful words found
  193. $result = ConvertTo-CleanBranchName -Name $Description
  194. $fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
  195. return [string]::Join('-', $fallbackWords)
  196. }
  197. }
  198. # Generate branch name
  199. if ($ShortName) {
  200. # Use provided short name, just clean it up
  201. $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
  202. } else {
  203. # Generate from description with smart filtering
  204. $branchSuffix = Get-BranchName -Description $featureDesc
  205. }
  206. # Warn if -Number and -Timestamp are both specified
  207. if ($Timestamp -and $Number -ne 0) {
  208. Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
  209. $Number = 0
  210. }
  211. # Determine branch prefix
  212. if ($Timestamp) {
  213. $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
  214. $branchName = "$featureNum-$branchSuffix"
  215. } else {
  216. # Determine branch number
  217. if ($Number -eq 0) {
  218. if ($DryRun -and $hasGit) {
  219. # Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
  220. $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
  221. } elseif ($DryRun) {
  222. # Dry-run without git: local spec dirs only
  223. $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
  224. } elseif ($hasGit) {
  225. # Check existing branches on remotes
  226. $Number = Get-NextBranchNumber -SpecsDir $specsDir
  227. } else {
  228. # Fall back to local directory check
  229. $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
  230. }
  231. }
  232. $featureNum = ('{0:000}' -f $Number)
  233. $branchName = "$featureNum-$branchSuffix"
  234. }
  235. # GitHub enforces a 244-byte limit on branch names
  236. # Validate and truncate if necessary
  237. $maxBranchLength = 244
  238. if ($branchName.Length -gt $maxBranchLength) {
  239. # Calculate how much we need to trim from suffix
  240. # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
  241. $prefixLength = $featureNum.Length + 1
  242. $maxSuffixLength = $maxBranchLength - $prefixLength
  243. # Truncate suffix
  244. $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
  245. # Remove trailing hyphen if truncation created one
  246. $truncatedSuffix = $truncatedSuffix -replace '-$', ''
  247. $originalBranchName = $branchName
  248. $branchName = "$featureNum-$truncatedSuffix"
  249. Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
  250. Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
  251. Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
  252. }
  253. $featureDir = Join-Path $specsDir $branchName
  254. $specFile = Join-Path $featureDir 'spec.md'
  255. if (-not $DryRun) {
  256. if ($hasGit) {
  257. $branchCreated = $false
  258. $branchCreateError = ''
  259. try {
  260. $branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
  261. if ($LASTEXITCODE -eq 0) {
  262. $branchCreated = $true
  263. }
  264. } catch {
  265. $branchCreateError = $_.Exception.Message
  266. }
  267. if (-not $branchCreated) {
  268. $currentBranch = ''
  269. try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
  270. # Check if branch already exists
  271. $existingBranch = git branch --list $branchName 2>$null
  272. if ($existingBranch) {
  273. if ($AllowExistingBranch) {
  274. # If we're already on the branch, continue without another checkout.
  275. if ($currentBranch -eq $branchName) {
  276. # Already on the target branch — nothing to do
  277. } else {
  278. # Otherwise switch to the existing branch instead of failing.
  279. $switchBranchError = git checkout -q $branchName 2>&1 | Out-String
  280. if ($LASTEXITCODE -ne 0) {
  281. if ($switchBranchError) {
  282. Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
  283. } else {
  284. Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
  285. }
  286. exit 1
  287. }
  288. }
  289. } elseif ($Timestamp) {
  290. Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
  291. exit 1
  292. } else {
  293. Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
  294. exit 1
  295. }
  296. } else {
  297. if ($branchCreateError) {
  298. Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
  299. } else {
  300. Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
  301. }
  302. exit 1
  303. }
  304. }
  305. } else {
  306. Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
  307. }
  308. New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
  309. if (-not (Test-Path -PathType Leaf $specFile)) {
  310. $template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
  311. if ($template -and (Test-Path $template)) {
  312. # Read the template content and write it to the spec file with UTF-8 encoding without BOM
  313. $content = [System.IO.File]::ReadAllText($template)
  314. $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
  315. [System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom)
  316. } else {
  317. New-Item -ItemType File -Path $specFile -Force | Out-Null
  318. }
  319. }
  320. # Set the SPECIFY_FEATURE environment variable for the current session
  321. $env:SPECIFY_FEATURE = $branchName
  322. }
  323. if ($Json) {
  324. $obj = [PSCustomObject]@{
  325. BRANCH_NAME = $branchName
  326. SPEC_FILE = $specFile
  327. FEATURE_NUM = $featureNum
  328. HAS_GIT = $hasGit
  329. }
  330. if ($DryRun) {
  331. $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
  332. }
  333. $obj | ConvertTo-Json -Compress
  334. } else {
  335. Write-Output "BRANCH_NAME: $branchName"
  336. Write-Output "SPEC_FILE: $specFile"
  337. Write-Output "FEATURE_NUM: $featureNum"
  338. Write-Output "HAS_GIT: $hasGit"
  339. if (-not $DryRun) {
  340. Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
  341. }
  342. }