Files
github-spec-kit/scripts/powershell/common.ps1
Manfred Riem 2972dec85c feat: Git extension stage 2 — GIT_BRANCH_NAME override, --force for existing dirs, auto-install tests (#1940) (#2117)
* feat: Git extension stage 2 — GIT_BRANCH_NAME override, --force for existing dirs, auto-install tests (#1940)

- Add GIT_BRANCH_NAME env var override to create-new-feature.sh/.ps1
  for exact branch naming (bypasses all prefix/suffix generation)
- Fix --force flag for 'specify init <dir>' into existing directories
- Add TestGitExtensionAutoInstall tests (auto-install, --no-git skip,
  commands registered)
- Add TestFeatureDirectoryResolution tests (env var, feature.json,
  priority, branch fallback)
- Document GIT_BRANCH_NAME in speckit.git.feature.md and specify.md

* fix: remove unused Tuple import (ruff F401)

* fix: address Copilot review feedback (#2117)

- Fix timestamp regex ordering: check YYYYMMDD-HHMMSS before generic
  numeric prefix in both bash and PowerShell
- Set BRANCH_SUFFIX in GIT_BRANCH_NAME override path so 244-byte
  truncation logic works correctly
- Add 244-byte length check for GIT_BRANCH_NAME in PowerShell
- Use existing_items for non-empty dir warning with --force
- Skip git extension install if already installed (idempotent --force)
- Wrap PowerShell feature.json parsing in try/catch for malformed JSON
- Fix PS comment: 'prefix lookup' -> 'exact mapping via Get-FeatureDir'
- Remove non-functional SPECIFY_SPEC_DIRECTORY from specify.md template

* fix: address second round of Copilot review feedback (#2117)

- Guard shutil.rmtree on init failure: skip cleanup when --force merged
  into a pre-existing directory (prevents data loss)
- Bash: error on GIT_BRANCH_NAME >244 bytes instead of broken truncation
- Fix malformed numbered list in specify.md (restore missing step 1)
- Add claude_skills.exists() assert before iterdir() in test

* fix: use UTF-8 byte count for 244-byte branch name limit (#2117)

- Bash: use LC_ALL=C wc -c for byte length instead of ${#VAR}
- PowerShell: use [System.Text.Encoding]::UTF8.GetByteCount() instead
  of .Length (UTF-16 code units)

* fix: address third round of review feedback (#2117)

- Update --dry-run help text in bash and PowerShell (branch name only)
- Fix specify.md JSON example: use concrete path, not literal variable
- Add TestForceExistingDirectory tests (merge + error without --force)
- Add PowerShell Get-FeaturePathsEnv tests (env var + feature.json)

* fix: normalize relative paths and fix Test-HasGit compat (#2117)

- Bash common.sh: normalize SPECIFY_FEATURE_DIRECTORY and feature.json
  relative paths to absolute under repo root
- PowerShell common.ps1: same normalization using IsPathRooted + Join-Path
- PowerShell create-new-feature.ps1: call Test-HasGit without -RepoRoot
  for compatibility with core common.ps1 (no param) and git-common.ps1
  (optional param with default)

* test: add GIT_BRANCH_NAME automated tests for bash and PowerShell (#2117)

- TestGitBranchNameOverrideBash: 5 tests (exact name, sequential prefix,
  timestamp prefix, overlong rejection, dry-run)
- TestGitBranchNameOverridePowerShell: 4 tests (exact name, sequential
  prefix, timestamp prefix, overlong rejection)
- Tests use extension scripts (not core) via new ext_git_repo and
  ext_ps_git_repo fixtures

* fix: restore git init during specify init + review fixes (#2117)

- Restore is_git_repo() and init_git_repo() functions removed in stage 2
- specify init now runs git init AND installs git extension (not just
  extension install alone)
- Add is_dir() guard for non-here path to prevent uncontrolled error
  when target exists but is a file
- Add python3 JSON fallback in common.sh for multi-line feature.json
  (grep pipeline fails on pretty-printed JSON without jq)

* fix: use init_git_repo error_msg in failure output (#2117)

* fix: ensure_executable_scripts also covers .specify/extensions/ (#2117)

Extension .sh scripts (e.g. create-new-feature.sh, initialize-repo.sh)
may lack execute bits after install. Scan both .specify/scripts/ and
.specify/extensions/ for permission fixing.

* fix: move chmod after extension install + sanitize error_msg (#2117)

- ensure_executable_scripts() now runs after git extension install so
  extension .sh files get execute bits in the same init run
- Sanitize init_git_repo error_msg to single line (replace newlines,
  truncate to 120 chars) to prevent garbled StepTracker output

* fix: use tracker.error for git init/extension failures (#2117)

Git init failure and extension install failure were reported as
tracker.complete (showing green) even on error. Now track a
git_has_error flag and call tracker.error when any step fails,
so the UI correctly reflects the failure state.

* fix: sanitize ext_err in git step tracker for consistent rendering (#2117)
2026-04-08 13:48:36 -05:00

298 lines
11 KiB
PowerShell

#!/usr/bin/env pwsh
# Common PowerShell functions analogous to common.sh
# Find repository root by searching upward for .specify directory
# This is the primary marker for spec-kit projects
function Find-SpecifyRoot {
param([string]$StartDir = (Get-Location).Path)
# Normalize to absolute path to prevent issues with relative paths
# Use -LiteralPath to handle paths with wildcard characters ([, ], *, ?)
$resolved = Resolve-Path -LiteralPath $StartDir -ErrorAction SilentlyContinue
$current = if ($resolved) { $resolved.Path } else { $null }
if (-not $current) { return $null }
while ($true) {
if (Test-Path -LiteralPath (Join-Path $current ".specify") -PathType Container) {
return $current
}
$parent = Split-Path $current -Parent
if ([string]::IsNullOrEmpty($parent) -or $parent -eq $current) {
return $null
}
$current = $parent
}
}
# Get repository root, prioritizing .specify directory over git
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
function Get-RepoRoot {
# First, look for .specify directory (spec-kit's own marker)
$specifyRoot = Find-SpecifyRoot
if ($specifyRoot) {
return $specifyRoot
}
# Fallback to git if no .specify found
try {
$result = git rev-parse --show-toplevel 2>$null
if ($LASTEXITCODE -eq 0) {
return $result
}
} catch {
# Git command failed
}
# Final fallback to script location for non-git repos
# Use -LiteralPath to handle paths with wildcard characters
return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path
}
function Get-CurrentBranch {
# First check if SPECIFY_FEATURE environment variable is set
if ($env:SPECIFY_FEATURE) {
return $env:SPECIFY_FEATURE
}
# Then check git if available at the spec-kit root (not parent)
$repoRoot = Get-RepoRoot
if (Test-HasGit) {
try {
$result = git -C $repoRoot rev-parse --abbrev-ref HEAD 2>$null
if ($LASTEXITCODE -eq 0) {
return $result
}
} catch {
# Git command failed
}
}
# For non-git repos, try to find the latest feature directory
$specsDir = Join-Path $repoRoot "specs"
if (Test-Path $specsDir) {
$latestFeature = ""
$highest = 0
$latestTimestamp = ""
Get-ChildItem -Path $specsDir -Directory | ForEach-Object {
if ($_.Name -match '^(\d{8}-\d{6})-') {
# Timestamp-based branch: compare lexicographically
$ts = $matches[1]
if ($ts -gt $latestTimestamp) {
$latestTimestamp = $ts
$latestFeature = $_.Name
}
} elseif ($_.Name -match '^(\d{3,})-') {
$num = [long]$matches[1]
if ($num -gt $highest) {
$highest = $num
# Only update if no timestamp branch found yet
if (-not $latestTimestamp) {
$latestFeature = $_.Name
}
}
}
}
if ($latestFeature) {
return $latestFeature
}
}
# Final fallback
return "main"
}
# Check if we have git available at the spec-kit root level
# Returns true only if git is installed and the repo root is inside a git work tree
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
function Test-HasGit {
# First check if git command is available (before calling Get-RepoRoot which may use git)
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
return $false
}
$repoRoot = Get-RepoRoot
# Check if .git exists (directory or file for worktrees/submodules)
# Use -LiteralPath to handle paths with wildcard characters
if (-not (Test-Path -LiteralPath (Join-Path $repoRoot ".git"))) {
return $false
}
# Verify it's actually a valid git work tree
try {
$null = git -C $repoRoot rev-parse --is-inside-work-tree 2>$null
return ($LASTEXITCODE -eq 0)
} catch {
return $false
}
}
function Test-FeatureBranch {
param(
[string]$Branch,
[bool]$HasGit = $true
)
# For non-git repos, we can't enforce branch naming but still provide output
if (-not $HasGit) {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
return $true
}
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
Write-Output "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name"
return $false
}
return $true
}
function Get-FeatureDir {
param([string]$RepoRoot, [string]$Branch)
Join-Path $RepoRoot "specs/$Branch"
}
function Get-FeaturePathsEnv {
$repoRoot = Get-RepoRoot
$currentBranch = Get-CurrentBranch
$hasGit = Test-HasGit
# Resolve feature directory. Priority:
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
# 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify)
# 3. Exact branch-to-directory mapping via Get-FeatureDir (legacy fallback)
$featureJson = Join-Path $repoRoot '.specify/feature.json'
if ($env:SPECIFY_FEATURE_DIRECTORY) {
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY
# Normalize relative paths to absolute under repo root
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
$featureDir = Join-Path $repoRoot $featureDir
}
} elseif (Test-Path $featureJson) {
try {
$featureConfig = Get-Content $featureJson -Raw | ConvertFrom-Json
if ($featureConfig.feature_directory) {
$featureDir = $featureConfig.feature_directory
# Normalize relative paths to absolute under repo root
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
$featureDir = Join-Path $repoRoot $featureDir
}
} else {
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
}
} catch {
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
}
} else {
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
}
[PSCustomObject]@{
REPO_ROOT = $repoRoot
CURRENT_BRANCH = $currentBranch
HAS_GIT = $hasGit
FEATURE_DIR = $featureDir
FEATURE_SPEC = Join-Path $featureDir 'spec.md'
IMPL_PLAN = Join-Path $featureDir 'plan.md'
TASKS = Join-Path $featureDir 'tasks.md'
RESEARCH = Join-Path $featureDir 'research.md'
DATA_MODEL = Join-Path $featureDir 'data-model.md'
QUICKSTART = Join-Path $featureDir 'quickstart.md'
CONTRACTS_DIR = Join-Path $featureDir 'contracts'
}
}
function Test-FileExists {
param([string]$Path, [string]$Description)
if (Test-Path -Path $Path -PathType Leaf) {
Write-Output "$Description"
return $true
} else {
Write-Output "$Description"
return $false
}
}
function Test-DirHasFiles {
param([string]$Path, [string]$Description)
if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1)) {
Write-Output "$Description"
return $true
} else {
Write-Output "$Description"
return $false
}
}
# Resolve a template name to a file path using the priority stack:
# 1. .specify/templates/overrides/
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
# 3. .specify/extensions/<ext-id>/templates/
# 4. .specify/templates/ (core)
function Resolve-Template {
param(
[Parameter(Mandatory=$true)][string]$TemplateName,
[Parameter(Mandatory=$true)][string]$RepoRoot
)
$base = Join-Path $RepoRoot '.specify/templates'
# Priority 1: Project overrides
$override = Join-Path $base "overrides/$TemplateName.md"
if (Test-Path $override) { return $override }
# Priority 2: Installed presets (sorted by priority from .registry)
$presetsDir = Join-Path $RepoRoot '.specify/presets'
if (Test-Path $presetsDir) {
$registryFile = Join-Path $presetsDir '.registry'
$sortedPresets = @()
if (Test-Path $registryFile) {
try {
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
$presets = $registryData.presets
if ($presets) {
$sortedPresets = $presets.PSObject.Properties |
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
ForEach-Object { $_.Name }
}
} catch {
# Fallback: alphabetical directory order
$sortedPresets = @()
}
}
if ($sortedPresets.Count -gt 0) {
foreach ($presetId in $sortedPresets) {
$candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}
} else {
# Fallback: alphabetical directory order
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}
}
}
# Priority 3: Extension-provided templates
$extDir = Join-Path $RepoRoot '.specify/extensions'
if (Test-Path $extDir) {
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}
}
# Priority 4: Core templates
$core = Join-Path $base "$TemplateName.md"
if (Test-Path $core) { return $core }
return $null
}