create-new-feature.ps1 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. #!/usr/bin/env pwsh
  2. # Git extension: create-new-feature.ps1
  3. # Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout.
  4. # Sources common.ps1 from the project's installed scripts, falling back to
  5. # git-common.ps1 for minimal git helpers.
  6. [CmdletBinding()]
  7. param(
  8. [switch]$Json,
  9. [switch]$AllowExistingBranch,
  10. [switch]$DryRun,
  11. [string]$ShortName,
  12. [Parameter()]
  13. [long]$Number = 0,
  14. [switch]$Timestamp,
  15. [switch]$Help,
  16. [Parameter(Position = 0, ValueFromRemainingArguments = $true)]
  17. [string[]]$FeatureDescription
  18. )
  19. $ErrorActionPreference = 'Stop'
  20. if ($Help) {
  21. Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
  22. Write-Host ""
  23. Write-Host "Options:"
  24. Write-Host " -Json Output in JSON format"
  25. Write-Host " -DryRun Compute branch name without creating the branch"
  26. Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
  27. Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
  28. Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
  29. Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
  30. Write-Host " -Help Show this help message"
  31. Write-Host ""
  32. Write-Host "Environment variables:"
  33. Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
  34. Write-Host ""
  35. exit 0
  36. }
  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. if ([string]::IsNullOrWhiteSpace($featureDesc)) {
  43. Write-Error "Error: Feature description cannot be empty or contain only whitespace"
  44. exit 1
  45. }
  46. function Get-HighestNumberFromSpecs {
  47. param([string]$SpecsDir)
  48. [long]$highest = 0
  49. if (Test-Path $SpecsDir) {
  50. Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
  51. if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
  52. [long]$num = 0
  53. if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
  54. $highest = $num
  55. }
  56. }
  57. }
  58. }
  59. return $highest
  60. }
  61. function Get-HighestNumberFromNames {
  62. param([string[]]$Names)
  63. [long]$highest = 0
  64. foreach ($name in $Names) {
  65. if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
  66. [long]$num = 0
  67. if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
  68. $highest = $num
  69. }
  70. }
  71. }
  72. return $highest
  73. }
  74. function Get-HighestNumberFromBranches {
  75. param()
  76. try {
  77. $branches = git branch -a 2>$null
  78. if ($LASTEXITCODE -eq 0 -and $branches) {
  79. $cleanNames = $branches | ForEach-Object {
  80. $_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
  81. }
  82. return Get-HighestNumberFromNames -Names $cleanNames
  83. }
  84. } catch {
  85. Write-Verbose "Could not check Git branches: $_"
  86. }
  87. return 0
  88. }
  89. function Get-HighestNumberFromRemoteRefs {
  90. [long]$highest = 0
  91. try {
  92. $remotes = git remote 2>$null
  93. if ($remotes) {
  94. foreach ($remote in $remotes) {
  95. $env:GIT_TERMINAL_PROMPT = '0'
  96. $refs = git ls-remote --heads $remote 2>$null
  97. $env:GIT_TERMINAL_PROMPT = $null
  98. if ($LASTEXITCODE -eq 0 -and $refs) {
  99. $refNames = $refs | ForEach-Object {
  100. if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
  101. } | Where-Object { $_ }
  102. $remoteHighest = Get-HighestNumberFromNames -Names $refNames
  103. if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
  104. }
  105. }
  106. }
  107. } catch {
  108. Write-Verbose "Could not query remote refs: $_"
  109. }
  110. return $highest
  111. }
  112. function Get-NextBranchNumber {
  113. param(
  114. [string]$SpecsDir,
  115. [switch]$SkipFetch
  116. )
  117. if ($SkipFetch) {
  118. $highestBranch = Get-HighestNumberFromBranches
  119. $highestRemote = Get-HighestNumberFromRemoteRefs
  120. $highestBranch = [Math]::Max($highestBranch, $highestRemote)
  121. } else {
  122. try {
  123. git fetch --all --prune 2>$null | Out-Null
  124. } catch { }
  125. $highestBranch = Get-HighestNumberFromBranches
  126. }
  127. $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
  128. $maxNum = [Math]::Max($highestBranch, $highestSpec)
  129. return $maxNum + 1
  130. }
  131. function ConvertTo-CleanBranchName {
  132. param([string]$Name)
  133. return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
  134. }
  135. # ---------------------------------------------------------------------------
  136. # Source common.ps1 from the project's installed scripts.
  137. # Search locations in priority order:
  138. # 1. .specify/scripts/powershell/common.ps1 under the project root
  139. # 2. scripts/powershell/common.ps1 under the project root (source checkout)
  140. # 3. git-common.ps1 next to this script (minimal fallback)
  141. # ---------------------------------------------------------------------------
  142. function Find-ProjectRoot {
  143. param([string]$StartDir)
  144. $current = Resolve-Path $StartDir
  145. while ($true) {
  146. foreach ($marker in @('.specify', '.git')) {
  147. if (Test-Path (Join-Path $current $marker)) {
  148. return $current
  149. }
  150. }
  151. $parent = Split-Path $current -Parent
  152. if ($parent -eq $current) { return $null }
  153. $current = $parent
  154. }
  155. }
  156. $projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot
  157. $commonLoaded = $false
  158. if ($projectRoot) {
  159. $candidates = @(
  160. (Join-Path $projectRoot ".specify/scripts/powershell/common.ps1"),
  161. (Join-Path $projectRoot "scripts/powershell/common.ps1")
  162. )
  163. foreach ($candidate in $candidates) {
  164. if (Test-Path $candidate) {
  165. . $candidate
  166. $commonLoaded = $true
  167. break
  168. }
  169. }
  170. }
  171. if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) {
  172. . "$PSScriptRoot/git-common.ps1"
  173. $commonLoaded = $true
  174. }
  175. if (-not $commonLoaded) {
  176. throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
  177. }
  178. # Resolve repository root
  179. if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
  180. $repoRoot = Get-RepoRoot
  181. } elseif ($projectRoot) {
  182. $repoRoot = $projectRoot
  183. } else {
  184. throw "Could not determine repository root."
  185. }
  186. # Check if git is available
  187. if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
  188. # Call without parameters for compatibility with core common.ps1 (no -RepoRoot param)
  189. # and git-common.ps1 (has -RepoRoot param with default).
  190. $hasGit = Test-HasGit
  191. } else {
  192. try {
  193. git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
  194. $hasGit = ($LASTEXITCODE -eq 0)
  195. } catch {
  196. $hasGit = $false
  197. }
  198. }
  199. Set-Location $repoRoot
  200. $specsDir = Join-Path $repoRoot 'specs'
  201. function Get-BranchName {
  202. param([string]$Description)
  203. $stopWords = @(
  204. 'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
  205. 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
  206. 'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
  207. 'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
  208. 'want', 'need', 'add', 'get', 'set'
  209. )
  210. $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
  211. $words = $cleanName -split '\s+' | Where-Object { $_ }
  212. $meaningfulWords = @()
  213. foreach ($word in $words) {
  214. if ($stopWords -contains $word) { continue }
  215. if ($word.Length -ge 3) {
  216. $meaningfulWords += $word
  217. } elseif ($Description -match "\b$($word.ToUpper())\b") {
  218. $meaningfulWords += $word
  219. }
  220. }
  221. if ($meaningfulWords.Count -gt 0) {
  222. $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
  223. $result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
  224. return $result
  225. } else {
  226. $result = ConvertTo-CleanBranchName -Name $Description
  227. $fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
  228. return [string]::Join('-', $fallbackWords)
  229. }
  230. }
  231. # Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
  232. if ($env:GIT_BRANCH_NAME) {
  233. $branchName = $env:GIT_BRANCH_NAME
  234. # Check 244-byte limit (UTF-8) for override names
  235. $branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName)
  236. if ($branchNameUtf8ByteCount -gt 244) {
  237. 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."
  238. }
  239. # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
  240. # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern
  241. if ($branchName -match '^(\d{8}-\d{6})-') {
  242. $featureNum = $matches[1]
  243. } elseif ($branchName -match '^(\d+)-') {
  244. $featureNum = $matches[1]
  245. } else {
  246. $featureNum = $branchName
  247. }
  248. } else {
  249. if ($ShortName) {
  250. $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
  251. } else {
  252. $branchSuffix = Get-BranchName -Description $featureDesc
  253. }
  254. if ($Timestamp -and $Number -ne 0) {
  255. Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
  256. $Number = 0
  257. }
  258. if ($Timestamp) {
  259. $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
  260. $branchName = "$featureNum-$branchSuffix"
  261. } else {
  262. if ($Number -eq 0) {
  263. if ($DryRun -and $hasGit) {
  264. $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
  265. } elseif ($DryRun) {
  266. $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
  267. } elseif ($hasGit) {
  268. $Number = Get-NextBranchNumber -SpecsDir $specsDir
  269. } else {
  270. $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
  271. }
  272. }
  273. $featureNum = ('{0:000}' -f $Number)
  274. $branchName = "$featureNum-$branchSuffix"
  275. }
  276. }
  277. $maxBranchLength = 244
  278. if ($branchName.Length -gt $maxBranchLength) {
  279. $prefixLength = $featureNum.Length + 1
  280. $maxSuffixLength = $maxBranchLength - $prefixLength
  281. $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
  282. $truncatedSuffix = $truncatedSuffix -replace '-$', ''
  283. $originalBranchName = $branchName
  284. $branchName = "$featureNum-$truncatedSuffix"
  285. Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
  286. Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
  287. Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
  288. }
  289. if (-not $DryRun) {
  290. if ($hasGit) {
  291. $branchCreated = $false
  292. $branchCreateError = ''
  293. try {
  294. $branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
  295. if ($LASTEXITCODE -eq 0) {
  296. $branchCreated = $true
  297. }
  298. } catch {
  299. $branchCreateError = $_.Exception.Message
  300. }
  301. if (-not $branchCreated) {
  302. $currentBranch = ''
  303. try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
  304. $existingBranch = git branch --list $branchName 2>$null
  305. if ($existingBranch) {
  306. if ($AllowExistingBranch) {
  307. if ($currentBranch -eq $branchName) {
  308. # Already on the target branch
  309. } else {
  310. $switchBranchError = git checkout -q $branchName 2>&1 | Out-String
  311. if ($LASTEXITCODE -ne 0) {
  312. if ($switchBranchError) {
  313. Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
  314. } else {
  315. Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
  316. }
  317. exit 1
  318. }
  319. }
  320. } elseif ($Timestamp) {
  321. Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
  322. exit 1
  323. } else {
  324. Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
  325. exit 1
  326. }
  327. } else {
  328. if ($branchCreateError) {
  329. Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
  330. } else {
  331. Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
  332. }
  333. exit 1
  334. }
  335. }
  336. } else {
  337. if ($Json) {
  338. [Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName")
  339. } else {
  340. Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
  341. }
  342. }
  343. $env:SPECIFY_FEATURE = $branchName
  344. }
  345. if ($Json) {
  346. $obj = [PSCustomObject]@{
  347. BRANCH_NAME = $branchName
  348. FEATURE_NUM = $featureNum
  349. HAS_GIT = $hasGit
  350. }
  351. if ($DryRun) {
  352. $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
  353. }
  354. $obj | ConvertTo-Json -Compress
  355. } else {
  356. Write-Output "BRANCH_NAME: $branchName"
  357. Write-Output "FEATURE_NUM: $featureNum"
  358. Write-Output "HAS_GIT: $hasGit"
  359. if (-not $DryRun) {
  360. Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
  361. }
  362. }