From 2972dec85c6370ef47275d3b4f072ecb31f7b36c Mon Sep 17 00:00:00 2001
From: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Date: Wed, 8 Apr 2026 13:48:36 -0500
Subject: [PATCH] =?UTF-8?q?feat:=20Git=20extension=20stage=202=20=E2=80=94?=
=?UTF-8?q?=20GIT=5FBRANCH=5FNAME=20override,=20--force=20for=20existing?=
=?UTF-8?q?=20dirs,=20auto-install=20tests=20(#1940)=20(#2117)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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
' 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)
---
.../git/commands/speckit.git.feature.md | 25 +-
.../git/scripts/bash/create-new-feature.sh | 127 ++++---
.../scripts/powershell/create-new-feature.ps1 | 104 +++---
scripts/bash/common.sh | 30 +-
scripts/powershell/common.ps1 | 31 +-
src/specify_cli/__init__.py | 190 ++++++-----
templates/commands/specify.md | 74 ++--
tests/extensions/git/test_git_extension.py | 22 +-
tests/integrations/test_cli.py | 141 ++++++++
tests/test_timestamp_branches.py | 318 ++++++++++++++++++
10 files changed, 805 insertions(+), 257 deletions(-)
diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md
index 13a7d0784..1a9c5e35d 100644
--- a/extensions/git/commands/speckit.git.feature.md
+++ b/extensions/git/commands/speckit.git.feature.md
@@ -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
diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh
index c83e8c613..286aaf763 100755
--- a/extensions/git/scripts/bash/create-new-feature.sh
+++ b/extensions/git/scripts/bash/create-new-feature.sh
@@ -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 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"
diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1
index ded6eaa72..b579f0516 100644
--- a/extensions/git/scripts/powershell/create-new-feature.ps1
+++ b/extensions/git/scripts/powershell/create-new-feature.ps1
@@ -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 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) {
diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh
index 5e45e8708..04af7d794 100644
--- a/scripts/bash/common.sh
+++ b/scripts/bash/common.sh
@@ -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
diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1
index 8c8c801ee..35ed884f0 100644
--- a/scripts/powershell/common.ps1
+++ b/scripts/powershell/common.ps1
@@ -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
diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py
index 95ab2028c..11b6e0eda 100644
--- a/src/specify_cli/__init__.py
+++ b/src/specify_cli/__init__.py
@@ -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:
diff --git a/templates/commands/specify.md b/templates/commands/specify.md
index a81b8f12f..15c75ec39 100644
--- a/templates/commands/specify.md
+++ b/templates/commands/specify.md
@@ -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: `-` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
+ - Set `SPECIFY_FEATURE_DIRECTORY` to `specs/`
+
+ **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": ""
+ }
+ ```
+ 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
diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py
index 721bd999f..098caf53b 100644
--- a/tests/extensions/git/test_git_extension.py
+++ b/tests/extensions/git/test_git_extension.py
@@ -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 ─────────────────────────────────────────────────────
diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py
index 945ce6ac6..1e23e35a7 100644
--- a/tests/integrations/test_cli.py
+++ b/tests/integrations/test_cli.py
@@ -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 --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 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"
diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py
index 605ae4896..2161d2893 100644
--- a/tests/test_timestamp_branches.py
+++ b/tests/test_timestamp_branches.py
@@ -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")