common.ps1 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. #!/usr/bin/env pwsh
  2. # Common PowerShell functions analogous to common.sh
  3. # Find repository root by searching upward for .specify directory
  4. # This is the primary marker for spec-kit projects
  5. function Find-SpecifyRoot {
  6. param([string]$StartDir = (Get-Location).Path)
  7. # Normalize to absolute path to prevent issues with relative paths
  8. # Use -LiteralPath to handle paths with wildcard characters ([, ], *, ?)
  9. $resolved = Resolve-Path -LiteralPath $StartDir -ErrorAction SilentlyContinue
  10. $current = if ($resolved) { $resolved.Path } else { $null }
  11. if (-not $current) { return $null }
  12. while ($true) {
  13. if (Test-Path -LiteralPath (Join-Path $current ".specify") -PathType Container) {
  14. return $current
  15. }
  16. $parent = Split-Path $current -Parent
  17. if ([string]::IsNullOrEmpty($parent) -or $parent -eq $current) {
  18. return $null
  19. }
  20. $current = $parent
  21. }
  22. }
  23. # Get repository root, prioritizing .specify directory over git
  24. # This prevents using a parent git repo when spec-kit is initialized in a subdirectory
  25. function Get-RepoRoot {
  26. # First, look for .specify directory (spec-kit's own marker)
  27. $specifyRoot = Find-SpecifyRoot
  28. if ($specifyRoot) {
  29. return $specifyRoot
  30. }
  31. # Fallback to git if no .specify found
  32. try {
  33. $result = git rev-parse --show-toplevel 2>$null
  34. if ($LASTEXITCODE -eq 0) {
  35. return $result
  36. }
  37. } catch {
  38. # Git command failed
  39. }
  40. # Final fallback to script location for non-git repos
  41. # Use -LiteralPath to handle paths with wildcard characters
  42. return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path
  43. }
  44. function Get-CurrentBranch {
  45. # First check if SPECIFY_FEATURE environment variable is set
  46. if ($env:SPECIFY_FEATURE) {
  47. return $env:SPECIFY_FEATURE
  48. }
  49. # Then check git if available at the spec-kit root (not parent)
  50. $repoRoot = Get-RepoRoot
  51. if (Test-HasGit) {
  52. try {
  53. $result = git -C $repoRoot rev-parse --abbrev-ref HEAD 2>$null
  54. if ($LASTEXITCODE -eq 0) {
  55. return $result
  56. }
  57. } catch {
  58. # Git command failed
  59. }
  60. }
  61. # For non-git repos, try to find the latest feature directory
  62. $specsDir = Join-Path $repoRoot "specs"
  63. if (Test-Path $specsDir) {
  64. $latestFeature = ""
  65. $highest = 0
  66. $latestTimestamp = ""
  67. Get-ChildItem -Path $specsDir -Directory | ForEach-Object {
  68. if ($_.Name -match '^(\d{8}-\d{6})-') {
  69. # Timestamp-based branch: compare lexicographically
  70. $ts = $matches[1]
  71. if ($ts -gt $latestTimestamp) {
  72. $latestTimestamp = $ts
  73. $latestFeature = $_.Name
  74. }
  75. } elseif ($_.Name -match '^(\d{3,})-') {
  76. $num = [long]$matches[1]
  77. if ($num -gt $highest) {
  78. $highest = $num
  79. # Only update if no timestamp branch found yet
  80. if (-not $latestTimestamp) {
  81. $latestFeature = $_.Name
  82. }
  83. }
  84. }
  85. }
  86. if ($latestFeature) {
  87. return $latestFeature
  88. }
  89. }
  90. # Final fallback
  91. return "main"
  92. }
  93. # Check if we have git available at the spec-kit root level
  94. # Returns true only if git is installed and the repo root is inside a git work tree
  95. # Handles both regular repos (.git directory) and worktrees/submodules (.git file)
  96. function Test-HasGit {
  97. # First check if git command is available (before calling Get-RepoRoot which may use git)
  98. if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
  99. return $false
  100. }
  101. $repoRoot = Get-RepoRoot
  102. # Check if .git exists (directory or file for worktrees/submodules)
  103. # Use -LiteralPath to handle paths with wildcard characters
  104. if (-not (Test-Path -LiteralPath (Join-Path $repoRoot ".git"))) {
  105. return $false
  106. }
  107. # Verify it's actually a valid git work tree
  108. try {
  109. $null = git -C $repoRoot rev-parse --is-inside-work-tree 2>$null
  110. return ($LASTEXITCODE -eq 0)
  111. } catch {
  112. return $false
  113. }
  114. }
  115. function Test-FeatureBranch {
  116. param(
  117. [string]$Branch,
  118. [bool]$HasGit = $true
  119. )
  120. # For non-git repos, we can't enforce branch naming but still provide output
  121. if (-not $HasGit) {
  122. Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
  123. return $true
  124. }
  125. # Accept sequential prefix (3+ digits) but exclude malformed timestamps
  126. # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
  127. $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
  128. $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
  129. if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
  130. Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
  131. Write-Output "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name"
  132. return $false
  133. }
  134. return $true
  135. }
  136. function Get-FeatureDir {
  137. param([string]$RepoRoot, [string]$Branch)
  138. Join-Path $RepoRoot "specs/$Branch"
  139. }
  140. function Get-FeaturePathsEnv {
  141. $repoRoot = Get-RepoRoot
  142. $currentBranch = Get-CurrentBranch
  143. $hasGit = Test-HasGit
  144. $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
  145. [PSCustomObject]@{
  146. REPO_ROOT = $repoRoot
  147. CURRENT_BRANCH = $currentBranch
  148. HAS_GIT = $hasGit
  149. FEATURE_DIR = $featureDir
  150. FEATURE_SPEC = Join-Path $featureDir 'spec.md'
  151. IMPL_PLAN = Join-Path $featureDir 'plan.md'
  152. TASKS = Join-Path $featureDir 'tasks.md'
  153. RESEARCH = Join-Path $featureDir 'research.md'
  154. DATA_MODEL = Join-Path $featureDir 'data-model.md'
  155. QUICKSTART = Join-Path $featureDir 'quickstart.md'
  156. CONTRACTS_DIR = Join-Path $featureDir 'contracts'
  157. }
  158. }
  159. function Test-FileExists {
  160. param([string]$Path, [string]$Description)
  161. if (Test-Path -Path $Path -PathType Leaf) {
  162. Write-Output " ✓ $Description"
  163. return $true
  164. } else {
  165. Write-Output " ✗ $Description"
  166. return $false
  167. }
  168. }
  169. function Test-DirHasFiles {
  170. param([string]$Path, [string]$Description)
  171. if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1)) {
  172. Write-Output " ✓ $Description"
  173. return $true
  174. } else {
  175. Write-Output " ✗ $Description"
  176. return $false
  177. }
  178. }
  179. # Resolve a template name to a file path using the priority stack:
  180. # 1. .specify/templates/overrides/
  181. # 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
  182. # 3. .specify/extensions/<ext-id>/templates/
  183. # 4. .specify/templates/ (core)
  184. function Resolve-Template {
  185. param(
  186. [Parameter(Mandatory=$true)][string]$TemplateName,
  187. [Parameter(Mandatory=$true)][string]$RepoRoot
  188. )
  189. $base = Join-Path $RepoRoot '.specify/templates'
  190. # Priority 1: Project overrides
  191. $override = Join-Path $base "overrides/$TemplateName.md"
  192. if (Test-Path $override) { return $override }
  193. # Priority 2: Installed presets (sorted by priority from .registry)
  194. $presetsDir = Join-Path $RepoRoot '.specify/presets'
  195. if (Test-Path $presetsDir) {
  196. $registryFile = Join-Path $presetsDir '.registry'
  197. $sortedPresets = @()
  198. if (Test-Path $registryFile) {
  199. try {
  200. $registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
  201. $presets = $registryData.presets
  202. if ($presets) {
  203. $sortedPresets = $presets.PSObject.Properties |
  204. Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
  205. ForEach-Object { $_.Name }
  206. }
  207. } catch {
  208. # Fallback: alphabetical directory order
  209. $sortedPresets = @()
  210. }
  211. }
  212. if ($sortedPresets.Count -gt 0) {
  213. foreach ($presetId in $sortedPresets) {
  214. $candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
  215. if (Test-Path $candidate) { return $candidate }
  216. }
  217. } else {
  218. # Fallback: alphabetical directory order
  219. foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
  220. $candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
  221. if (Test-Path $candidate) { return $candidate }
  222. }
  223. }
  224. }
  225. # Priority 3: Extension-provided templates
  226. $extDir = Join-Path $RepoRoot '.specify/extensions'
  227. if (Test-Path $extDir) {
  228. foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
  229. $candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
  230. if (Test-Path $candidate) { return $candidate }
  231. }
  232. }
  233. # Priority 4: Core templates
  234. $core = Join-Path $base "$TemplateName.md"
  235. if (Test-Path $core) { return $core }
  236. return $null
  237. }