mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
* feat: add git extension with hooks on all core commands - Create extensions/git/ with 5 commands: initialize, feature, validate, remote, commit - 18 hooks covering before/after for all 9 core commands - Scripts: create-new-feature, initialize-repo, auto-commit, git-common (bash + powershell) - Configurable: branch_numbering, init_commit_message, per-command auto-commit with custom messages - Add hooks to analyze, checklist, clarify, constitution, taskstoissues command templates - Allow hooks-only extensions (no commands required) - Bundle extension in wheel via pyproject.toml force-include - Resolve bundled extensions locally before catalog lookup - Remove planned-but-unimplemented before/after_commit hook refs - Update extension docs (API ref, dev guide, user guide) - 37 new tests covering manifest, install, all scripts (bash+pwsh), config reading, graceful degradation Stage 1: opt-in via 'specify extension add git'. No auto-install, no changes to specify.md or core git init code. Refs: #841, #1382, #1066, #1791, #1191 * fix: set git identity env vars in extension tests for CI runners * fix: address PR review comments - Fix commands property KeyError for hooks-only extensions - Fix has_git() operator precedence in git-common.sh - Align default commit message to '[Spec Kit] Initial commit' across config-template, extension.yml defaults, and both init scripts - Update README to reflect all 5 commands and 18 hooks * fix: address second round of PR review comments - Add type validation for provides.commands (must be list) and hooks (must be dict) in manifest _validate() - Tighten malformed timestamp detection in git-common.sh to catch 7-digit dates without trailing slug (e.g. 2026031-143022) - Pass REPO_ROOT to has_git/Test-HasGit in create-new-feature scripts - Fix initialize command docs: surface errors on git failures, only skip when git is not installed - Fix commit command docs: 'skips with a warning' not 'silently' - Add tests for commands:null and hooks:list rejection * fix: address third round of PR review comments - Remove scripts frontmatter from command files (CommandRegistrar rewrites ../../scripts/ to .specify/scripts/ which points at core scripts, not extension scripts) - Update speckit.git.commit command to derive event name from hook context rather than using a static example - Clarify that hook argument passthrough works via AI agent context (the agent carries conversation state including user's original feature description) * fix: address fourth round of PR review comments - Validate extension_id against ^[a-z0-9-]+$ in _locate_bundled_extension to prevent path traversal (security fix) - Move defaults under config.defaults in extension.yml to match ConfigManager._get_extension_defaults() schema - Ship git-config.yml in extension directory so it's copied during install (provides.config template isn't materialized by ExtensionManager) - Condition handling in hook templates: intentionally matches existing pattern from specify/plan/tasks/implement templates (not a new issue) * fix: add --allow-empty to git commit in initialize-repo scripts Ensures git init succeeds even on empty repos where nothing has been staged yet. * fix: resolve display names to bundled extensions before catalog download When 'specify extension add "Git Branching Workflow"' is used with a display name instead of the ID, the catalog resolver now runs first to map the name to an ID, then checks bundled extensions again with the resolved ID before falling back to network download. Also noted: EXECUTE_COMMAND_INVOCATION and condition handling match the existing pattern in specify/plan/tasks/implement templates (pre-existing, not introduced by this PR). * fix: handle before_/after_ prefixes in auto-commit message derivation - Strip both before_ and after_ prefixes when deriving command name (fixes misleading 'Auto-commit after before_plan' messages) - Include phase (before/after) in default commit messages - Clarify README config example is an override, not default behavior * fix: use portable grep -qw for word boundary in create-new-feature.sh BSD grep (macOS) doesn't support \b as a word boundary. Replace with grep -qw which is POSIX-portable. * fix: validate hook values, numeric --number, and PS warning routing - Validate each hook value is a dict with a 'command' field during manifest _validate() (prevents crash at install time) - Validate --number is a non-negative integer in bash create-new-feature (clear error instead of cryptic shell arithmetic failure) - Route PowerShell no-git warning to stderr in JSON mode so stdout stays valid JSON --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
398 lines
14 KiB
PowerShell
398 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 and paths without creating branches, directories, or files"
|
|
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"
|
|
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) {
|
|
$hasGit = Test-HasGit -RepoRoot $repoRoot
|
|
} 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'
|
|
if (-not $DryRun) {
|
|
New-Item -ItemType Directory -Path $specsDir -Force | Out-Null
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)"
|
|
}
|
|
|
|
$featureDir = Join-Path $specsDir $branchName
|
|
$specFile = Join-Path $featureDir 'spec.md'
|
|
|
|
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 {
|
|
git checkout -q $branchName 2>$null | Out-Null
|
|
if ($LASTEXITCODE -ne 0) {
|
|
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"
|
|
}
|
|
}
|
|
|
|
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
|
|
|
if (-not (Test-Path -PathType Leaf $specFile)) {
|
|
if (Get-Command Resolve-Template -ErrorAction SilentlyContinue) {
|
|
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
|
|
} else {
|
|
$template = $null
|
|
}
|
|
if ($template -and (Test-Path $template)) {
|
|
Copy-Item $template $specFile -Force
|
|
} else {
|
|
New-Item -ItemType File -Path $specFile -Force | Out-Null
|
|
}
|
|
}
|
|
|
|
$env:SPECIFY_FEATURE = $branchName
|
|
}
|
|
|
|
if ($Json) {
|
|
$obj = [PSCustomObject]@{
|
|
BRANCH_NAME = $branchName
|
|
SPEC_FILE = $specFile
|
|
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 "SPEC_FILE: $specFile"
|
|
Write-Output "FEATURE_NUM: $featureNum"
|
|
Write-Output "HAS_GIT: $hasGit"
|
|
if (-not $DryRun) {
|
|
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
|
}
|
|
}
|