mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user