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)
This commit is contained in:
Manfred Riem
2026-04-08 13:48:36 -05:00
committed by GitHub
parent 838bd0fedc
commit 2972dec85c
10 changed files with 805 additions and 257 deletions

View File

@@ -4,7 +4,7 @@ description: "Create a feature branch with sequential or timestamp numbering"
# Create Feature Branch
Create a new feature branch for the given specification.
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
## User Input
@@ -14,10 +14,17 @@ $ARGUMENTS
You **MUST** consider the user input before proceeding (if not empty).
## Environment Variable Override
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
- `--short-name`, `--number`, and `--timestamp` flags are ignored
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
## Prerequisites
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, warn the user and skip branch creation (spec directory will still be created)
- If Git is not available, warn the user and skip branch creation
## Branch Numbering Mode
@@ -45,22 +52,16 @@ Run the appropriate script based on your platform:
- Do NOT pass `--number` — the script determines the correct next number automatically
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
- You must only ever run this script once per feature
- The JSON output will contain BRANCH_NAME and SPEC_FILE paths
If the extension scripts are not found at the `.specify/extensions/git/` path, fall back to:
- **Bash**: `scripts/bash/create-new-feature.sh`
- **PowerShell**: `scripts/powershell/create-new-feature.ps1`
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
## Graceful Degradation
If Git is not installed or the current directory is not a Git repository:
- The script will still create the spec directory under `specs/`
- A warning will be printed: `[specify] Warning: Git repository not detected; skipped branch creation`
- The workflow continues normally without branch creation
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
## Output
The script outputs JSON with:
- `BRANCH_NAME`: The created branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- `SPEC_FILE`: Path to the created spec file
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- `FEATURE_NUM`: The numeric or timestamp prefix used

View File

@@ -64,17 +64,21 @@ while [ $i -le $# ]; do
echo ""
echo "Options:"
echo " --json Output in JSON format"
echo " --dry-run Compute branch name and paths without creating branches, directories, or files"
echo " --dry-run Compute branch name without creating the branch"
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
echo " --number N Specify branch number manually (overrides auto-detection)"
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
echo " --help, -h Show this help message"
echo ""
echo "Environment variables:"
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
echo ""
echo "Examples:"
echo " $0 'Add user authentication system' --short-name 'user-auth'"
echo " $0 'Implement OAuth2 integration for API' --number 5"
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
echo " GIT_BRANCH_NAME=my-branch $0 'feature description'"
exit 0
;;
*)
@@ -258,9 +262,6 @@ fi
cd "$REPO_ROOT"
SPECS_DIR="$REPO_ROOT/specs"
if [ "$DRY_RUN" != true ]; then
mkdir -p "$SPECS_DIR"
fi
# Function to generate branch name with stop word filtering
generate_branch_name() {
@@ -301,45 +302,67 @@ generate_branch_name() {
fi
}
# Generate branch name
if [ -n "$SHORT_NAME" ]; then
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
if [ -n "${GIT_BRANCH_NAME:-}" ]; then
BRANCH_NAME="$GIT_BRANCH_NAME"
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern
if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}')
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+')
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
else
FEATURE_NUM="$BRANCH_NAME"
BRANCH_SUFFIX="$BRANCH_NAME"
fi
else
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
fi
# Warn if --number and --timestamp are both specified
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
BRANCH_NUMBER=""
fi
# Determine branch prefix
if [ "$USE_TIMESTAMP" = true ]; then
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
else
if [ -z "$BRANCH_NUMBER" ]; then
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
elif [ "$DRY_RUN" = true ]; then
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
elif [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
else
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
fi
# Generate branch name
if [ -n "$SHORT_NAME" ]; then
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
else
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
fi
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
# Warn if --number and --timestamp are both specified
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
BRANCH_NUMBER=""
fi
# Determine branch prefix
if [ "$USE_TIMESTAMP" = true ]; then
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
else
if [ -z "$BRANCH_NUMBER" ]; then
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
elif [ "$DRY_RUN" = true ]; then
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
elif [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
else
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
fi
fi
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
fi
fi
# GitHub enforces a 244-byte limit on branch names
MAX_BRANCH_LENGTH=244
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; }
BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
>&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
exit 1
elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
@@ -354,9 +377,6 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
fi
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
SPEC_FILE="$FEATURE_DIR/spec.md"
if [ "$DRY_RUN" != true ]; then
if [ "$HAS_GIT" = true ]; then
branch_create_error=""
@@ -394,22 +414,6 @@ if [ "$DRY_RUN" != true ]; then
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
fi
mkdir -p "$FEATURE_DIR"
if [ ! -f "$SPEC_FILE" ]; then
if type resolve_template >/dev/null 2>&1; then
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
else
TEMPLATE=""
fi
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
cp "$TEMPLATE" "$SPEC_FILE"
else
echo "Warning: Spec template not found; created empty spec file" >&2
touch "$SPEC_FILE"
fi
fi
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
fi
@@ -418,35 +422,30 @@ if $JSON_MODE; then
if [ "$DRY_RUN" = true ]; then
jq -cn \
--arg branch_name "$BRANCH_NAME" \
--arg spec_file "$SPEC_FILE" \
--arg feature_num "$FEATURE_NUM" \
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}'
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}'
else
jq -cn \
--arg branch_name "$BRANCH_NAME" \
--arg spec_file "$SPEC_FILE" \
--arg feature_num "$FEATURE_NUM" \
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}'
fi
else
if type json_escape >/dev/null 2>&1; then
_je_branch=$(json_escape "$BRANCH_NAME")
_je_spec=$(json_escape "$SPEC_FILE")
_je_num=$(json_escape "$FEATURE_NUM")
else
_je_branch="$BRANCH_NAME"
_je_spec="$SPEC_FILE"
_je_num="$FEATURE_NUM"
fi
if [ "$DRY_RUN" = true ]; then
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_spec" "$_je_num"
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num"
else
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_spec" "$_je_num"
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num"
fi
fi
else
echo "BRANCH_NAME: $BRANCH_NAME"
echo "SPEC_FILE: $SPEC_FILE"
echo "FEATURE_NUM: $FEATURE_NUM"
if [ "$DRY_RUN" != true ]; then
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"

View File

@@ -23,12 +23,16 @@ if ($Help) {
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 " -DryRun Compute branch name without creating the branch"
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"
Write-Host ""
Write-Host "Environment variables:"
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
Write-Host ""
exit 0
}
@@ -203,7 +207,9 @@ if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
# Check if git is available
if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
$hasGit = Test-HasGit -RepoRoot $repoRoot
# Call without parameters for compatibility with core common.ps1 (no -RepoRoot param)
# and git-common.ps1 (has -RepoRoot param with default).
$hasGit = Test-HasGit
} else {
try {
git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
@@ -216,9 +222,6 @@ if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
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)
@@ -255,35 +258,54 @@ function Get-BranchName {
}
}
if ($ShortName) {
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
if ($env:GIT_BRANCH_NAME) {
$branchName = $env:GIT_BRANCH_NAME
# Check 244-byte limit (UTF-8) for override names
$branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName)
if ($branchNameUtf8ByteCount -gt 244) {
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."
}
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern
if ($branchName -match '^(\d{8}-\d{6})-') {
$featureNum = $matches[1]
} elseif ($branchName -match '^(\d+)-') {
$featureNum = $matches[1]
} else {
$featureNum = $branchName
}
} 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
}
if ($ShortName) {
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
} else {
$branchSuffix = Get-BranchName -Description $featureDesc
}
$featureNum = ('{0:000}' -f $Number)
$branchName = "$featureNum-$branchSuffix"
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
@@ -302,9 +324,6 @@ if ($branchName.Length -gt $maxBranchLength) {
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
@@ -361,28 +380,12 @@ if (-not $DryRun) {
}
}
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
}
@@ -392,7 +395,6 @@ if ($Json) {
$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) {

View File

@@ -194,9 +194,35 @@ get_feature_paths() {
has_git_repo="true"
fi
# Use prefix-based lookup to support multiple branches per spec
# Resolve feature directory. Priority:
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
# 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify)
# 3. Branch-name-based prefix lookup (legacy fallback)
local feature_dir
if ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
feature_dir="$SPECIFY_FEATURE_DIRECTORY"
# Normalize relative paths to absolute under repo root
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
local _fd
if command -v jq >/dev/null 2>&1; then
_fd=$(jq -r '.feature_directory // empty' "$repo_root/.specify/feature.json" 2>/dev/null)
elif command -v python3 >/dev/null 2>&1; then
# Fallback: use Python to parse JSON so pretty-printed/multi-line files work
_fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('feature_directory',''))" "$repo_root/.specify/feature.json" 2>/dev/null)
else
# Last resort: single-line grep fallback (won't work on multi-line JSON)
_fd=$(grep -o '"feature_directory"[[:space:]]*:[[:space:]]*"[^"]*"' "$repo_root/.specify/feature.json" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/')
fi
if [[ -n "$_fd" ]]; then
feature_dir="$_fd"
# Normalize relative paths to absolute under repo root
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
echo "ERROR: Failed to resolve feature directory" >&2
return 1
fi
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
echo "ERROR: Failed to resolve feature directory" >&2
return 1
fi

View File

@@ -160,7 +160,36 @@ function Get-FeaturePathsEnv {
$repoRoot = Get-RepoRoot
$currentBranch = Get-CurrentBranch
$hasGit = Test-HasGit
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
# 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

View File

@@ -35,7 +35,7 @@ import json5
import stat
import yaml
from pathlib import Path
from typing import Any, Optional, Tuple
from typing import Any, Optional
import typer
from rich.console import Console
@@ -384,6 +384,7 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool:
return found
def is_git_repo(path: Path = None) -> bool:
"""Check if the specified path is inside a git repository."""
if path is None:
@@ -393,7 +394,6 @@ def is_git_repo(path: Path = None) -> bool:
return False
try:
# Use git command to check if inside a work tree
subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
check=True,
@@ -404,16 +404,9 @@ def is_git_repo(path: Path = None) -> bool:
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Optional[str]]:
"""Initialize a git repository in the specified path.
Args:
project_path: Path to initialize git repository in
quiet: if True suppress console output (tracker handles status)
Returns:
Tuple of (success: bool, error_message: Optional[str])
"""
def init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, Optional[str]]:
"""Initialize a git repository in the specified path."""
try:
original_cwd = Path.cwd()
os.chdir(project_path)
@@ -425,20 +418,19 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Option
if not quiet:
console.print("[green]✓[/green] Git repository initialized")
return True, None
except subprocess.CalledProcessError as e:
error_msg = f"Command: {' '.join(e.cmd)}\nExit code: {e.returncode}"
if e.stderr:
error_msg += f"\nError: {e.stderr.strip()}"
elif e.stdout:
error_msg += f"\nOutput: {e.stdout.strip()}"
if not quiet:
console.print(f"[red]Error initializing git repository:[/red] {e}")
return False, error_msg
finally:
os.chdir(original_cwd)
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
"""Handle merging or copying of .vscode/settings.json files.
@@ -708,41 +700,45 @@ def _install_shared_infra(
def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None:
"""Ensure POSIX .sh scripts under .specify/scripts (recursively) have execute bits (no-op on Windows)."""
"""Ensure POSIX .sh scripts under .specify/scripts and .specify/extensions (recursively) have execute bits (no-op on Windows)."""
if os.name == "nt":
return # Windows: skip silently
scripts_root = project_path / ".specify" / "scripts"
if not scripts_root.is_dir():
return
scan_roots = [
project_path / ".specify" / "scripts",
project_path / ".specify" / "extensions",
]
failures: list[str] = []
updated = 0
for script in scripts_root.rglob("*.sh"):
try:
if script.is_symlink() or not script.is_file():
continue
for scripts_root in scan_roots:
if not scripts_root.is_dir():
continue
for script in scripts_root.rglob("*.sh"):
try:
with script.open("rb") as f:
if f.read(2) != b"#!":
continue
except Exception:
continue
st = script.stat()
mode = st.st_mode
if mode & 0o111:
continue
new_mode = mode
if mode & 0o400:
new_mode |= 0o100
if mode & 0o040:
new_mode |= 0o010
if mode & 0o004:
new_mode |= 0o001
if not (new_mode & 0o100):
new_mode |= 0o100
os.chmod(script, new_mode)
updated += 1
except Exception as e:
failures.append(f"{script.relative_to(scripts_root)}: {e}")
if script.is_symlink() or not script.is_file():
continue
try:
with script.open("rb") as f:
if f.read(2) != b"#!":
continue
except Exception:
continue
st = script.stat()
mode = st.st_mode
if mode & 0o111:
continue
new_mode = mode
if mode & 0o400:
new_mode |= 0o100
if mode & 0o040:
new_mode |= 0o010
if mode & 0o004:
new_mode |= 0o001
if not (new_mode & 0o100):
new_mode |= 0o100
os.chmod(script, new_mode)
updated += 1
except Exception as e:
failures.append(f"{script.relative_to(project_path)}: {e}")
if tracker:
detail = f"{updated} updated" + (f", {len(failures)} failed" if failures else "")
tracker.add("chmod", "Set script permissions recursively")
@@ -993,9 +989,11 @@ def init(
console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}")
raise typer.Exit(1)
dir_existed_before = False
if here:
project_name = Path.cwd().name
project_path = Path.cwd()
dir_existed_before = True
existing_items = list(project_path.iterdir())
if existing_items:
@@ -1010,17 +1008,29 @@ def init(
raise typer.Exit(0)
else:
project_path = Path(project_name).resolve()
dir_existed_before = project_path.exists()
if project_path.exists():
error_panel = Panel(
f"Directory '[cyan]{project_name}[/cyan]' already exists\n"
"Please choose a different project name or remove the existing directory.",
title="[red]Directory Conflict[/red]",
border_style="red",
padding=(1, 2)
)
console.print()
console.print(error_panel)
raise typer.Exit(1)
if not project_path.is_dir():
console.print(f"[red]Error:[/red] '{project_name}' exists but is not a directory.")
raise typer.Exit(1)
existing_items = list(project_path.iterdir())
if force:
if existing_items:
console.print(f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)")
console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]")
console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]")
else:
error_panel = Panel(
f"Directory '[cyan]{project_name}[/cyan]' already exists\n"
"Please choose a different project name or remove the existing directory.\n"
"Use [bold]--force[/bold] to merge into the existing directory.",
title="[red]Directory Conflict[/red]",
border_style="red",
padding=(1, 2)
)
console.print()
console.print(error_panel)
raise typer.Exit(1)
if ai_assistant:
if ai_assistant not in AGENT_CONFIG:
@@ -1123,14 +1133,11 @@ def init(
for key, label in [
("chmod", "Ensure scripts executable"),
("constitution", "Constitution setup"),
("git", "Initialize git repository"),
("git", "Install git extension"),
("final", "Finalize"),
]:
tracker.add(key, label)
# Track git error message outside Live context so it persists
git_error_message = None
with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live:
tracker.attach_refresh(lambda: live.update(tracker.render()))
try:
@@ -1177,26 +1184,62 @@ def init(
_install_shared_infra(project_path, selected_script, tracker=tracker)
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
ensure_executable_scripts(project_path, tracker=tracker)
ensure_constitution_from_template(project_path, tracker=tracker)
if not no_git:
tracker.start("git")
git_messages = []
git_has_error = False
# Step 1: Initialize git repo if needed
if is_git_repo(project_path):
tracker.complete("git", "existing repo detected")
git_messages.append("existing repo detected")
elif should_init_git:
success, error_msg = init_git_repo(project_path, quiet=True)
if success:
tracker.complete("git", "initialized")
git_messages.append("initialized")
else:
tracker.error("git", "init failed")
git_error_message = error_msg
git_has_error = True
# Sanitize multi-line error_msg to single line for tracker
if error_msg:
sanitized = error_msg.replace('\n', ' ').strip()
git_messages.append(f"init failed: {sanitized[:120]}")
else:
git_messages.append("init failed")
else:
tracker.skip("git", "git not available")
git_messages.append("git not available")
# Step 2: Install bundled git extension
try:
from .extensions import ExtensionManager
bundled_path = _locate_bundled_extension("git")
if bundled_path:
manager = ExtensionManager(project_path)
if manager.registry.is_installed("git"):
git_messages.append("extension already installed")
else:
manager.install_from_directory(
bundled_path, get_speckit_version()
)
git_messages.append("extension installed")
else:
git_has_error = True
git_messages.append("bundled extension not found")
except Exception as ext_err:
git_has_error = True
sanitized_ext = str(ext_err).replace('\n', ' ').strip()
git_messages.append(
f"extension install failed: {sanitized_ext[:120]}"
)
summary = "; ".join(git_messages)
if git_has_error:
tracker.error("git", summary)
else:
tracker.complete("git", summary)
else:
tracker.skip("git", "--no-git flag")
# Fix permissions after all installs (scripts + extensions)
ensure_executable_scripts(project_path, tracker=tracker)
# Persist the CLI options so later operations (e.g. preset add)
# can adapt their behaviour without re-scanning the filesystem.
# Must be saved BEFORE preset install so _get_skills_dir() works.
@@ -1262,7 +1305,7 @@ def init(
_label_width = max(len(k) for k, _ in _env_pairs)
env_lines = [f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" for k, v in _env_pairs]
console.print(Panel("\n".join(env_lines), title="Debug Environment", border_style="magenta"))
if not here and project_path.exists():
if not here and project_path.exists() and not dir_existed_before:
shutil.rmtree(project_path)
raise typer.Exit(1)
finally:
@@ -1271,23 +1314,6 @@ def init(
console.print(tracker.render())
console.print("\n[bold green]Project ready.[/bold green]")
# Show git error details if initialization failed
if git_error_message:
console.print()
git_error_panel = Panel(
f"[yellow]Warning:[/yellow] Git repository initialization failed\n\n"
f"{git_error_message}\n\n"
f"[dim]You can initialize git manually later with:[/dim]\n"
f"[cyan]cd {project_path if not here else '.'}[/cyan]\n"
f"[cyan]git init[/cyan]\n"
f"[cyan]git add .[/cyan]\n"
f"[cyan]git commit -m \"Initial commit\"[/cyan]",
title="[red]Git Initialization Failed[/red]",
border_style="red",
padding=(1, 2)
)
console.print(git_error_panel)
# Agent folder security notice
agent_config = AGENT_CONFIG.get(selected_ai)
if agent_config:

View File

@@ -8,9 +8,6 @@ handoffs:
agent: speckit.clarify
prompt: Clarify specification requirements
send: true
scripts:
sh: scripts/bash/create-new-feature.sh "{ARGS}"
ps: scripts/powershell/create-new-feature.ps1 "{ARGS}"
---
## User Input
@@ -61,7 +58,7 @@ The text the user typed after `/speckit.specify` in the triggering message **is*
Given that feature description, do this:
1. **Generate a concise short name** (2-4 words) for the branch:
1. **Generate a concise short name** (2-4 words) for the feature:
- Analyze the feature description and extract the most meaningful keywords
- Create a 2-4 word short name that captures the essence of the feature
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
@@ -73,30 +70,47 @@ Given that feature description, do this:
- "Create a dashboard for analytics" → "analytics-dashboard"
- "Fix payment processing timeout bug" → "fix-payment-timeout"
2. **Create the feature branch** by running the script with `--short-name` (and `--json`). In sequential mode, do NOT pass `--number` — the script auto-detects the next available number. In timestamp mode, the script generates a `YYYYMMDD-HHMMSS` prefix automatically:
2. **Branch creation** (optional, via hook):
**Branch numbering mode**: Before running the script, check if `.specify/init-options.json` exists and read the `branch_numbering` value.
- If `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation
- If `"sequential"` or absent, do not add any extra flag (default behavior)
If a `before_specify` hook ran successfully in the Pre-Execution Checks above, it will have created/switched to a git branch and output JSON containing `BRANCH_NAME` and `FEATURE_NUM`. Note these values for reference, but the branch name does **not** dictate the spec directory name.
- Bash example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"`
- Bash (timestamp): `{SCRIPT} --json --timestamp --short-name "user-auth" "Add user authentication"`
- PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"`
- PowerShell (timestamp): `{SCRIPT} -Json -Timestamp -ShortName "user-auth" "Add user authentication"`
If the user explicitly provided `GIT_BRANCH_NAME`, pass it through to the hook so the branch script uses the exact value as the branch name (bypassing all prefix/suffix generation).
3. **Create the spec feature directory**:
Specs live under the default `specs/` directory unless the user explicitly provides `SPECIFY_FEATURE_DIRECTORY`.
**Resolution order for `SPECIFY_FEATURE_DIRECTORY`**:
1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is
2. Otherwise, auto-generate it under `specs/`:
- Check `.specify/init-options.json` for `branch_numbering`
- If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp)
- If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`)
- Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>`
**Create the directory and spec file**:
- `mkdir -p SPECIFY_FEATURE_DIRECTORY`
- Copy `templates/spec-template.md` to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point
- Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md`
- Persist the resolved path to `.specify/feature.json`:
```json
{
"feature_directory": "<resolved feature dir>"
}
```
Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`.
This allows downstream commands (`/speckit.plan`, `/speckit.tasks`, etc.) to locate the feature directory without relying on git branch name conventions.
**IMPORTANT**:
- Do NOT pass `--number` — the script determines the correct next number automatically
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
- You must only ever run this script once per feature
- The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for
- The JSON output will contain BRANCH_NAME and SPEC_FILE paths
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot")
- You must only create one feature per `/speckit.specify` invocation
- The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
- The spec directory and file are always created by this command, never by the hook
3. Load `templates/spec-template.md` to understand required sections.
4. Load `templates/spec-template.md` to understand required sections.
4. Follow this execution flow:
1. Parse user description from Input
5. Follow this execution flow:
1. Parse user description from arguments
If empty: ERROR "No feature description provided"
2. Extract key concepts from description
Identify: actors, actions, data, constraints
@@ -120,11 +134,11 @@ Given that feature description, do this:
7. Identify Key Entities (if data involved)
8. Return: SUCCESS (spec ready for planning)
5. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
6. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
6. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
7. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
a. **Create Spec Quality Checklist**: Generate a checklist file at `FEATURE_DIR/checklists/requirements.md` using the checklist template structure with these validation items:
a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items:
```markdown
# Specification Quality Checklist: [FEATURE NAME]
@@ -214,9 +228,13 @@ Given that feature description, do this:
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`).
8. **Report completion** to the user with:
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
- `SPEC_FILE` — the spec file path
- Checklist results summary
- Readiness for the next phase (`/speckit.clarify` or `/speckit.plan`)
8. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_specify` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
@@ -245,7 +263,7 @@ Given that feature description, do this:
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing.
**NOTE:** Branch creation is handled by the `before_specify` hook (git extension). Spec directory and file creation are always handled by this core command.
## Quick Guidelines

View File

@@ -280,7 +280,6 @@ class TestCreateFeatureBash:
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "001-user-auth"
assert "SPEC_FILE" in data
assert data["FEATURE_NUM"] == "001"
def test_creates_branch_timestamp(self, tmp_path: Path):
@@ -294,18 +293,6 @@ class TestCreateFeatureBash:
data = json.loads(result.stdout)
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
def test_creates_spec_dir(self, tmp_path: Path):
"""create-new-feature.sh creates specs directory and spec.md."""
project = _setup_project(tmp_path)
result = _run_bash(
"create-new-feature.sh", project,
"--json", "--short-name", "test-feat", "Test feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
spec_file = Path(data["SPEC_FILE"])
assert spec_file.exists(), f"spec.md not created at {spec_file}"
def test_increments_from_existing_specs(self, tmp_path: Path):
"""Sequential numbering increments past existing spec directories."""
project = _setup_project(tmp_path)
@@ -321,7 +308,7 @@ class TestCreateFeatureBash:
assert data["FEATURE_NUM"] == "003"
def test_no_git_graceful_degradation(self, tmp_path: Path):
"""create-new-feature.sh works without git (creates spec dir only)."""
"""create-new-feature.sh works without git (outputs branch name, skips branch creation)."""
project = _setup_project(tmp_path, git=False)
result = _run_bash(
"create-new-feature.sh", project,
@@ -330,8 +317,8 @@ class TestCreateFeatureBash:
assert result.returncode == 0, result.stderr
assert "Warning" in result.stderr
data = json.loads(result.stdout)
spec_file = Path(data["SPEC_FILE"])
assert spec_file.exists()
assert "BRANCH_NAME" in data
assert "FEATURE_NUM" in data
def test_dry_run(self, tmp_path: Path):
"""--dry-run computes branch name without creating anything."""
@@ -382,7 +369,8 @@ class TestCreateFeaturePowerShell:
json_line = [l for l in result.stdout.splitlines() if l.strip().startswith("{")]
assert json_line, f"No JSON in output: {result.stdout}"
data = json.loads(json_line[-1])
assert Path(data["SPEC_FILE"]).exists()
assert "BRANCH_NAME" in data
assert "FEATURE_NUM" in data
# ── auto-commit.sh Tests ─────────────────────────────────────────────────────

View File

@@ -3,6 +3,8 @@
import json
import os
import yaml
class TestInitIntegrationFlag:
def test_integration_and_ai_mutually_exclusive(self, tmp_path):
@@ -147,3 +149,142 @@ class TestInitIntegrationFlag:
# Other shared files should still be installed
assert (scripts_dir / "setup-plan.sh").exists()
assert (templates_dir / "plan-template.md").exists()
class TestForceExistingDirectory:
"""Tests for --force merging into an existing named directory."""
def test_force_merges_into_existing_dir(self, tmp_path):
"""specify init <dir> --force succeeds when the directory already exists."""
from typer.testing import CliRunner
from specify_cli import app
target = tmp_path / "existing-proj"
target.mkdir()
# Place a pre-existing file to verify it survives the merge
marker = target / "user-file.txt"
marker.write_text("keep me", encoding="utf-8")
runner = CliRunner()
result = runner.invoke(app, [
"init", str(target), "--integration", "copilot", "--force",
"--no-git", "--script", "sh",
], catch_exceptions=False)
assert result.exit_code == 0, f"init --force failed: {result.output}"
# Pre-existing file should survive
assert marker.read_text(encoding="utf-8") == "keep me"
# Spec Kit files should be installed
assert (target / ".specify" / "init-options.json").exists()
assert (target / ".specify" / "templates" / "spec-template.md").exists()
def test_without_force_errors_on_existing_dir(self, tmp_path):
"""specify init <dir> without --force errors when directory exists."""
from typer.testing import CliRunner
from specify_cli import app
target = tmp_path / "existing-proj"
target.mkdir()
runner = CliRunner()
result = runner.invoke(app, [
"init", str(target), "--integration", "copilot",
"--no-git", "--script", "sh",
], catch_exceptions=False)
assert result.exit_code == 1
assert "already exists" in result.output
class TestGitExtensionAutoInstall:
"""Tests for auto-installation of the git extension during specify init."""
def test_git_extension_auto_installed(self, tmp_path):
"""Without --no-git, the git extension is installed during init."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "git-auto"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "claude", "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
# Check that the tracker didn't report a git error
assert "install failed" not in result.output, f"git extension install failed: {result.output}"
# Git extension files should be installed
ext_dir = project / ".specify" / "extensions" / "git"
assert ext_dir.exists(), "git extension directory not installed"
assert (ext_dir / "extension.yml").exists()
assert (ext_dir / "scripts" / "bash" / "create-new-feature.sh").exists()
assert (ext_dir / "scripts" / "bash" / "initialize-repo.sh").exists()
# Hooks should be registered
extensions_yml = project / ".specify" / "extensions.yml"
assert extensions_yml.exists(), "extensions.yml not created"
hooks_data = yaml.safe_load(extensions_yml.read_text(encoding="utf-8"))
assert "hooks" in hooks_data
assert "before_specify" in hooks_data["hooks"]
assert "before_constitution" in hooks_data["hooks"]
def test_no_git_skips_extension(self, tmp_path):
"""With --no-git, the git extension is NOT installed."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "no-git"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "claude", "--script", "sh",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
# Git extension should NOT be installed
ext_dir = project / ".specify" / "extensions" / "git"
assert not ext_dir.exists(), "git extension should not be installed with --no-git"
def test_git_extension_commands_registered(self, tmp_path):
"""Git extension commands are registered with the agent during init."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "git-cmds"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "claude", "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
# Git extension commands should be registered with the agent
claude_skills = project / ".claude" / "skills"
assert claude_skills.exists(), "Claude skills directory was not created"
git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")]
assert len(git_skills) > 0, "no git extension commands registered"

View File

@@ -4,6 +4,7 @@ Pytest tests for timestamp-based branch naming in create-new-feature.sh and comm
Converted from tests/test_timestamp_branches.sh so they are discovered by `uv run pytest`.
"""
import json
import os
import re
import shutil
@@ -22,6 +23,8 @@ EXT_CREATE_FEATURE_PS = (
PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
)
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
EXT_CREATE_FEATURE = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
EXT_CREATE_FEATURE_PS = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
@pytest.fixture
@@ -47,6 +50,62 @@ def git_repo(tmp_path: Path) -> Path:
return tmp_path
@pytest.fixture
def ext_git_repo(tmp_path: Path) -> Path:
"""Create a temp git repo with extension scripts (for GIT_BRANCH_NAME tests)."""
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True)
subprocess.run(["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=tmp_path, check=True)
# Extension script needs common.sh at .specify/scripts/bash/
specify_scripts = tmp_path / ".specify" / "scripts" / "bash"
specify_scripts.mkdir(parents=True)
shutil.copy(COMMON_SH, specify_scripts / "common.sh")
# Also install core scripts for compatibility
core_scripts = tmp_path / "scripts" / "bash"
core_scripts.mkdir(parents=True)
shutil.copy(COMMON_SH, core_scripts / "common.sh")
# Copy extension script
ext_dir = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash"
ext_dir.mkdir(parents=True)
shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature.sh")
# Also copy git-common.sh if it exists
git_common = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
if git_common.exists():
shutil.copy(git_common, ext_dir / "git-common.sh")
(tmp_path / ".specify" / "templates").mkdir(parents=True, exist_ok=True)
(tmp_path / "specs").mkdir(exist_ok=True)
return tmp_path
@pytest.fixture
def ext_ps_git_repo(tmp_path: Path) -> Path:
"""Create a temp git repo with PowerShell extension scripts."""
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True)
subprocess.run(["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=tmp_path, check=True)
# Install core PS scripts
ps_dir = tmp_path / "scripts" / "powershell"
ps_dir.mkdir(parents=True)
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
shutil.copy(common_ps, ps_dir / "common.ps1")
# Also install at .specify/scripts/powershell/ for extension resolution
specify_ps = tmp_path / ".specify" / "scripts" / "powershell"
specify_ps.mkdir(parents=True)
shutil.copy(common_ps, specify_ps / "common.ps1")
# Copy extension script
ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell"
ext_ps.mkdir(parents=True)
shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature.ps1")
git_common_ps = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
if git_common_ps.exists():
shutil.copy(git_common_ps, ext_ps / "git-common.ps1")
(tmp_path / ".specify" / "templates").mkdir(parents=True, exist_ok=True)
(tmp_path / "specs").mkdir(exist_ok=True)
return tmp_path
@pytest.fixture
def no_git_dir(tmp_path: Path) -> Path:
"""Create a temp directory without git, but with scripts."""
@@ -837,3 +896,262 @@ class TestPowerShellDryRun:
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}"
# ── GIT_BRANCH_NAME Override Tests ──────────────────────────────────────────
class TestGitBranchNameOverrideBash:
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh."""
def _run_ext(self, ext_git_repo: Path, env_extras: dict, *extra_args: str):
script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
cmd = ["bash", str(script), "--json", *extra_args, "ignored"]
return subprocess.run(cmd, cwd=ext_git_repo, capture_output=True, text=True,
env={**os.environ, **env_extras})
def test_exact_name_no_prefix(self, ext_git_repo: Path):
"""GIT_BRANCH_NAME is used verbatim with no numeric prefix added."""
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "my-exact-branch"})
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "my-exact-branch"
assert data["FEATURE_NUM"] == "my-exact-branch"
def test_sequential_prefix_extraction(self, ext_git_repo: Path):
"""FEATURE_NUM extracted from sequential-style prefix (digits before dash)."""
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "042-custom-branch"})
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "042-custom-branch"
assert data["FEATURE_NUM"] == "042"
def test_timestamp_prefix_extraction(self, ext_git_repo: Path):
"""FEATURE_NUM extracted as full YYYYMMDD-HHMMSS for timestamp-style names."""
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "20260407-143022-my-feature"})
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "20260407-143022-my-feature"
assert data["FEATURE_NUM"] == "20260407-143022"
def test_overlong_name_rejected(self, ext_git_repo: Path):
"""GIT_BRANCH_NAME exceeding 244 bytes is rejected with an error."""
long_name = "a" * 245
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": long_name})
assert result.returncode != 0
assert "244" in result.stderr
def test_dry_run_with_override(self, ext_git_repo: Path):
"""GIT_BRANCH_NAME works with --dry-run (no branch created)."""
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "dry-run-override"}, "--dry-run")
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "dry-run-override"
assert data.get("DRY_RUN") is True
branches = subprocess.run(
["git", "branch", "--list", "dry-run-override"],
cwd=ext_git_repo, capture_output=True, text=True,
)
assert "dry-run-override" not in branches.stdout
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
class TestGitBranchNameOverridePowerShell:
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.ps1."""
def _run_ext(self, ext_ps_git_repo: Path, env_extras: dict):
script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
return subprocess.run(
["pwsh", "-NoProfile", "-File", str(script), "-Json", "ignored"],
cwd=ext_ps_git_repo, capture_output=True, text=True,
env={**os.environ, **env_extras},
)
def test_exact_name_no_prefix(self, ext_ps_git_repo: Path):
"""GIT_BRANCH_NAME is used verbatim with no numeric prefix added."""
result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "ps-exact-branch"})
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "ps-exact-branch"
assert data["FEATURE_NUM"] == "ps-exact-branch"
def test_sequential_prefix_extraction(self, ext_ps_git_repo: Path):
"""FEATURE_NUM extracted from sequential-style prefix."""
result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "099-ps-numbered"})
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "099-ps-numbered"
assert data["FEATURE_NUM"] == "099"
def test_timestamp_prefix_extraction(self, ext_ps_git_repo: Path):
"""FEATURE_NUM extracted as full YYYYMMDD-HHMMSS for timestamp-style names."""
result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "20260407-143022-ps-feature"})
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "20260407-143022-ps-feature"
assert data["FEATURE_NUM"] == "20260407-143022"
def test_overlong_name_rejected(self, ext_ps_git_repo: Path):
"""GIT_BRANCH_NAME exceeding 244 bytes is rejected."""
long_name = "a" * 245
result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": long_name})
assert result.returncode != 0
assert "244" in result.stderr
# ── Feature Directory Resolution Tests ───────────────────────────────────────
class TestFeatureDirectoryResolution:
"""Tests for SPECIFY_FEATURE_DIRECTORY and .specify/feature.json resolution."""
def test_env_var_overrides_branch_lookup(self, git_repo: Path):
"""SPECIFY_FEATURE_DIRECTORY env var takes priority over branch-based lookup."""
custom_dir = git_repo / "my-custom-specs" / "my-feature"
custom_dir.mkdir(parents=True)
result = subprocess.run(
["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'],
cwd=git_repo,
capture_output=True,
text=True,
env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(custom_dir)},
)
assert result.returncode == 0, result.stderr
assert str(custom_dir) in result.stdout
for line in result.stdout.splitlines():
if line.startswith("FEATURE_DIR="):
val = line.split("=", 1)[1].strip("'\"")
assert val == str(custom_dir)
break
else:
pytest.fail("FEATURE_DIR not found in output")
def test_feature_json_overrides_branch_lookup(self, git_repo: Path):
"""feature.json feature_directory takes priority over branch-based lookup."""
custom_dir = git_repo / "specs" / "custom-feature"
custom_dir.mkdir(parents=True)
feature_json = git_repo / ".specify" / "feature.json"
feature_json.write_text(
f'{{"feature_directory": "{custom_dir}"}}\n',
encoding="utf-8",
)
result = subprocess.run(
["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'],
cwd=git_repo,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
for line in result.stdout.splitlines():
if line.startswith("FEATURE_DIR="):
val = line.split("=", 1)[1].strip("'\"")
assert val == str(custom_dir)
break
else:
pytest.fail("FEATURE_DIR not found in output")
def test_env_var_takes_priority_over_feature_json(self, git_repo: Path):
"""Env var wins over feature.json."""
env_dir = git_repo / "specs" / "env-feature"
env_dir.mkdir(parents=True)
json_dir = git_repo / "specs" / "json-feature"
json_dir.mkdir(parents=True)
feature_json = git_repo / ".specify" / "feature.json"
feature_json.write_text(
f'{{"feature_directory": "{json_dir}"}}\n',
encoding="utf-8",
)
result = subprocess.run(
["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'],
cwd=git_repo,
capture_output=True,
text=True,
env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(env_dir)},
)
assert result.returncode == 0, result.stderr
for line in result.stdout.splitlines():
if line.startswith("FEATURE_DIR="):
val = line.split("=", 1)[1].strip("'\"")
assert val == str(env_dir)
break
else:
pytest.fail("FEATURE_DIR not found in output")
def test_fallback_to_branch_lookup(self, git_repo: Path):
"""Without env var or feature.json, falls back to branch-based lookup."""
subprocess.run(["git", "checkout", "-q", "-b", "001-test-feat"], cwd=git_repo, check=True)
spec_dir = git_repo / "specs" / "001-test-feat"
spec_dir.mkdir(parents=True)
result = subprocess.run(
["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'],
cwd=git_repo,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
for line in result.stdout.splitlines():
if line.startswith("FEATURE_DIR="):
val = line.split("=", 1)[1].strip("'\"")
assert val == str(spec_dir)
break
else:
pytest.fail("FEATURE_DIR not found in output")
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_ps_env_var_overrides_branch_lookup(self, git_repo: Path):
"""PowerShell: SPECIFY_FEATURE_DIRECTORY env var takes priority."""
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
custom_dir = git_repo / "my-custom-specs" / "ps-feature"
custom_dir.mkdir(parents=True)
ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"'
result = subprocess.run(
["pwsh", "-NoProfile", "-Command", ps_cmd],
cwd=git_repo,
capture_output=True,
text=True,
env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(custom_dir)},
)
assert result.returncode == 0, result.stderr
for line in result.stdout.splitlines():
if line.startswith("FEATURE_DIR="):
val = line.split("=", 1)[1].strip("'\"")
assert val == str(custom_dir)
break
else:
pytest.fail("FEATURE_DIR not found in PowerShell output")
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_ps_feature_json_overrides_branch_lookup(self, git_repo: Path):
"""PowerShell: feature.json takes priority over branch-based lookup."""
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
custom_dir = git_repo / "specs" / "ps-json-feature"
custom_dir.mkdir(parents=True)
feature_json = git_repo / ".specify" / "feature.json"
feature_json.write_text(
f'{{"feature_directory": "{custom_dir}"}}\n',
encoding="utf-8",
)
ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"'
result = subprocess.run(
["pwsh", "-NoProfile", "-Command", ps_cmd],
cwd=git_repo,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
for line in result.stdout.splitlines():
if line.startswith("FEATURE_DIR="):
val = line.split("=", 1)[1].strip("'\"")
assert val == str(custom_dir)
break
else:
pytest.fail("FEATURE_DIR not found in PowerShell output")