diff --git a/extensions/git/README.md b/extensions/git/README.md index 31ba75c30f..5cfa8278da 100644 --- a/extensions/git/README.md +++ b/extensions/git/README.md @@ -7,7 +7,7 @@ Git repository initialization, feature branch creation, numbering (sequential/ti This extension provides Git operations as an optional, self-contained module. It manages: - **Repository initialization** with configurable commit messages -- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering +- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering, with optional branch prefix (e.g., `feature`, `bugfix`) - **Branch validation** to ensure branches follow naming conventions - **Git remote detection** for GitHub integration (e.g., issue creation) - **Auto-commit** after core commands (configurable per-command with custom messages) @@ -65,6 +65,16 @@ auto_commit: message: "[Spec Kit] Add specification" ``` +### Branch Prefix + +The `create-new-feature` scripts accept a `--prefix` (Bash) / `-Prefix` (PowerShell) option to prepend a custom prefix to branch names. Common prefixes: + +- `feature` — new features or enhancements +- `bugfix` — bug fixes +- `hotfix` — urgent production fixes + +The trailing `/` is optional — the script auto-appends it if missing. The final branch name format is `{prefix}/{number}-{short-name}` (e.g., `--prefix "feature"` produces `feature/001-user-auth`). + ## Installation ```bash diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md index 5bed9e5e57..235c9723ef 100644 --- a/extensions/git/commands/speckit.git.feature.md +++ b/extensions/git/commands/speckit.git.feature.md @@ -34,6 +34,17 @@ Determine the branch numbering strategy by checking configuration in this order: 2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility) 3. Default to `sequential` if neither exists +## Branch Prefix + +Determine the branch prefix based on the nature of the work: + +- Use `feature` for new features or enhancements +- Use `bugfix` for bug fixes +- Use `hotfix` for urgent production fixes +- Use no prefix (omit `--prefix`) for the default flat naming + +Choose the most appropriate prefix based on the feature description and context. The trailing `/` is optional — the script auto-appends it if missing. The final branch name format is `{prefix}/{number}-{short-name}` (e.g., `--prefix "feature"` produces `feature/001-user-auth`). + ## Execution Generate a concise short name (2-4 words) for the branch: @@ -43,10 +54,12 @@ Generate a concise short name (2-4 words) for the branch: Run the appropriate script based on your platform: -- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "" ""` -- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "" ""` -- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "" ""` -- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "" ""` +- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --prefix "" --short-name "" ""` +- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --prefix "" --timestamp --short-name "" ""` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Prefix "" -ShortName "" ""` +- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Prefix "" -Timestamp -ShortName "" ""` + +If no prefix is needed, omit `--prefix` / `-Prefix` entirely. **IMPORTANT**: - Do NOT pass `--number` — the script determines the correct next number automatically diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh index f7aa31610e..19881dbe08 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -11,6 +11,7 @@ DRY_RUN=false ALLOW_EXISTING=false SHORT_NAME="" BRANCH_NUMBER="" +BRANCH_PREFIX="" USE_TIMESTAMP=false ARGS=() i=1 @@ -59,8 +60,21 @@ while [ $i -le $# ]; do --timestamp) USE_TIMESTAMP=true ;; + --prefix) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --prefix requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --prefix requires a value' >&2 + exit 1 + fi + BRANCH_PREFIX="$next_arg" + ;; --help|-h) - echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] [--prefix ] " echo "" echo "Options:" echo " --json Output in JSON format" @@ -69,6 +83,7 @@ while [ $i -le $# ]; do 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 " --prefix Custom prefix for the branch name (e.g. 'feature', 'bugfix')" echo " --help, -h Show this help message" echo "" echo "Environment variables:" @@ -88,9 +103,25 @@ while [ $i -le $# ]; do i=$((i + 1)) done +# Validate and normalize branch prefix +if [ -n "$BRANCH_PREFIX" ]; then + BRANCH_PREFIX=$(echo "$BRANCH_PREFIX" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + if [ -z "$BRANCH_PREFIX" ]; then + echo 'Error: --prefix cannot be empty or whitespace' >&2 + exit 1 + fi + # Strip optional trailing '/' before checking for embedded slashes + _check_prefix="${BRANCH_PREFIX%/}" + if [[ "$_check_prefix" == */* ]]; then + echo 'Error: --prefix must be a single segment (no embedded slashes); e.g. "feature", "bugfix"' >&2 + exit 1 + fi + BRANCH_PREFIX="$_check_prefix/" +fi + FEATURE_DESCRIPTION="${ARGS[*]}" if [ -z "$FEATURE_DESCRIPTION" ]; then - echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] [--prefix ] " >&2 exit 1 fi @@ -134,6 +165,10 @@ _extract_highest_number() { local highest=0 while IFS= read -r name; do [ -z "$name" ] && continue + # Strip optional prefix segment (e.g., "feature/003-name" -> "003-name") + if [[ "$name" =~ ^([^/]+)/([^/]+)$ ]]; then + name="${BASH_REMATCH[2]}" + fi if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0") number=$((10#$number)) @@ -334,7 +369,7 @@ else # Determine branch prefix if [ "$USE_TIMESTAMP" = true ]; then FEATURE_NUM=$(date +%Y%m%d-%H%M%S) - BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + BRANCH_NAME="${BRANCH_PREFIX}${FEATURE_NUM}-${BRANCH_SUFFIX}" else if [ -z "$BRANCH_NUMBER" ]; then if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then @@ -351,7 +386,7 @@ else fi FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") - BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + BRANCH_NAME="${BRANCH_PREFIX}${FEATURE_NUM}-${BRANCH_SUFFIX}" fi fi @@ -363,14 +398,14 @@ if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH >&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 )) + PREFIX_LENGTH=$(( ${#BRANCH_PREFIX} + ${#FEATURE_NUM} + 1 )) MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') ORIGINAL_BRANCH_NAME="$BRANCH_NAME" - BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + BRANCH_NAME="${BRANCH_PREFIX}${FEATURE_NUM}-${TRUNCATED_SUFFIX}" >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 index b579f05160..e454b7fef5 100644 --- a/extensions/git/scripts/powershell/create-new-feature.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -9,6 +9,7 @@ param( [switch]$AllowExistingBranch, [switch]$DryRun, [string]$ShortName, + [string]$Prefix = "", [Parameter()] [long]$Number = 0, [switch]$Timestamp, @@ -19,13 +20,14 @@ param( $ErrorActionPreference = 'Stop' if ($Help) { - Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Prefix ] [-Number N] [-Timestamp] " Write-Host "" Write-Host "Options:" Write-Host " -Json Output in JSON format" 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 " -Prefix Custom prefix for the branch name (e.g. 'feature', 'bugfix')" 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" @@ -37,12 +39,27 @@ if ($Help) { } if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { - Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Prefix ] [-Number N] [-Timestamp] " exit 1 } $featureDesc = ($FeatureDescription -join ' ').Trim() +# Validate and normalize branch prefix +if ($Prefix) { + $Prefix = $Prefix.Trim() + if ([string]::IsNullOrWhiteSpace($Prefix)) { + Write-Error "Error: -Prefix cannot be empty or whitespace" + exit 1 + } + $checkPrefix = $Prefix.TrimEnd('/') + if ($checkPrefix.Contains('/')) { + Write-Error "Error: -Prefix must be a single segment (no embedded slashes); e.g. 'feature', 'bugfix'" + exit 1 + } + $Prefix = "$checkPrefix/" +} + if ([string]::IsNullOrWhiteSpace($featureDesc)) { Write-Error "Error: Feature description cannot be empty or contain only whitespace" exit 1 @@ -70,6 +87,10 @@ function Get-HighestNumberFromNames { [long]$highest = 0 foreach ($name in $Names) { + # Strip optional prefix segment (e.g., "feature/003-name" -> "003-name") + if ($name -match '^[^/]+/([^/]+)$') { + $name = $Matches[1] + } if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') { [long]$num = 0 if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { @@ -289,7 +310,7 @@ if ($env:GIT_BRANCH_NAME) { if ($Timestamp) { $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' - $branchName = "$featureNum-$branchSuffix" + $branchName = "$Prefix$featureNum-$branchSuffix" } else { if ($Number -eq 0) { if ($DryRun -and $hasGit) { @@ -304,20 +325,20 @@ if ($env:GIT_BRANCH_NAME) { } $featureNum = ('{0:000}' -f $Number) - $branchName = "$featureNum-$branchSuffix" + $branchName = "$Prefix$featureNum-$branchSuffix" } } $maxBranchLength = 244 if ($branchName.Length -gt $maxBranchLength) { - $prefixLength = $featureNum.Length + 1 + $prefixLength = $Prefix.Length + $featureNum.Length + 1 $maxSuffixLength = $maxBranchLength - $prefixLength $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) $truncatedSuffix = $truncatedSuffix -replace '-$', '' $originalBranchName = $branchName - $branchName = "$featureNum-$truncatedSuffix" + $branchName = "$Prefix$featureNum-$truncatedSuffix" Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index c3537704f6..1f24d2dfa3 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -7,6 +7,7 @@ DRY_RUN=false ALLOW_EXISTING=false SHORT_NAME="" BRANCH_NUMBER="" +BRANCH_PREFIX="" USE_TIMESTAMP=false ARGS=() i=1 @@ -52,8 +53,22 @@ while [ $i -le $# ]; do --timestamp) USE_TIMESTAMP=true ;; + --prefix) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --prefix requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + # Check if the next argument is another option (starts with --) + if [[ "$next_arg" == --* ]]; then + echo 'Error: --prefix requires a value' >&2 + exit 1 + fi + BRANCH_PREFIX="$next_arg" + ;; --help|-h) - echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] [--prefix ] " echo "" echo "Options:" echo " --json Output in JSON format" @@ -62,6 +77,7 @@ while [ $i -le $# ]; do 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 " --prefix Custom prefix for the branch name (e.g. 'feature', 'bugfix')" echo " --help, -h Show this help message" echo "" echo "Examples:" @@ -77,9 +93,25 @@ while [ $i -le $# ]; do i=$((i + 1)) done +# Validate and normalize branch prefix +if [ -n "$BRANCH_PREFIX" ]; then + BRANCH_PREFIX=$(echo "$BRANCH_PREFIX" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + if [ -z "$BRANCH_PREFIX" ]; then + echo 'Error: --prefix cannot be empty or whitespace' >&2 + exit 1 + fi + # Strip optional trailing '/' before checking for embedded slashes + _check_prefix="${BRANCH_PREFIX%/}" + if [[ "$_check_prefix" == */* ]]; then + echo 'Error: --prefix must be a single segment (no embedded slashes); e.g. "feature", "bugfix"' >&2 + exit 1 + fi + BRANCH_PREFIX="$_check_prefix/" +fi + FEATURE_DESCRIPTION="${ARGS[*]}" if [ -z "$FEATURE_DESCRIPTION" ]; then - echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] [--prefix ] " >&2 exit 1 fi @@ -124,6 +156,10 @@ _extract_highest_number() { local highest=0 while IFS= read -r name; do [ -z "$name" ] && continue + # Strip optional prefix segment (e.g., "feature/003-name" -> "003-name") + if [[ "$name" =~ ^([^/]+)/([^/]+)$ ]]; then + name="${BASH_REMATCH[2]}" + fi if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0") number=$((10#$number)) @@ -274,7 +310,7 @@ fi # Determine branch prefix if [ "$USE_TIMESTAMP" = true ]; then FEATURE_NUM=$(date +%Y%m%d-%H%M%S) - BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + BRANCH_NAME="${BRANCH_PREFIX}${FEATURE_NUM}-${BRANCH_SUFFIX}" else # Determine branch number if [ -z "$BRANCH_NUMBER" ]; then @@ -297,32 +333,36 @@ else # Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") - BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + BRANCH_NAME="${BRANCH_PREFIX}${FEATURE_NUM}-${BRANCH_SUFFIX}" fi +# Directory-safe name (no prefix slash) for specs/ paths +FEATURE_DIR_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + # GitHub enforces a 244-byte limit on branch names # Validate and truncate if necessary MAX_BRANCH_LENGTH=244 if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then # Calculate how much we need to trim from suffix # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 - PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) + PREFIX_LENGTH=$(( ${#BRANCH_PREFIX} + ${#FEATURE_NUM} + 1 )) MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) - + # Truncate suffix at word boundary if possible TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) # Remove trailing hyphen if truncation created one TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') - + ORIGINAL_BRANCH_NAME="$BRANCH_NAME" - BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" - + BRANCH_NAME="${BRANCH_PREFIX}${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + FEATURE_DIR_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" fi -FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +FEATURE_DIR="$SPECS_DIR/$FEATURE_DIR_NAME" SPEC_FILE="$FEATURE_DIR/spec.md" if [ "$DRY_RUN" != true ]; then @@ -378,7 +418,7 @@ if [ "$DRY_RUN" != true ]; then fi # Inform the user how to persist the feature variable in their own shell - printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 + printf '# To persist: export SPECIFY_FEATURE=%q\n' "$FEATURE_DIR_NAME" >&2 fi if $JSON_MODE; then @@ -408,6 +448,6 @@ else 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" + printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$FEATURE_DIR_NAME" fi fi diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 2f23283fc4..af181fabd9 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -6,6 +6,7 @@ param( [switch]$AllowExistingBranch, [switch]$DryRun, [string]$ShortName, + [string]$Prefix = "", [Parameter()] [long]$Number = 0, [switch]$Timestamp, @@ -17,13 +18,14 @@ $ErrorActionPreference = 'Stop' # Show help if requested if ($Help) { - Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Prefix ] [-Number N] [-Timestamp] " Write-Host "" Write-Host "Options:" Write-Host " -Json Output in JSON format" Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files" Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing" Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" + Write-Host " -Prefix Custom prefix for the branch name (e.g. 'feature', 'bugfix')" 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" @@ -37,12 +39,27 @@ if ($Help) { # Check if feature description provided if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { - Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Prefix ] [-Number N] [-Timestamp] " exit 1 } $featureDesc = ($FeatureDescription -join ' ').Trim() +# Validate and normalize branch prefix +if ($Prefix) { + $Prefix = $Prefix.Trim() + if ([string]::IsNullOrWhiteSpace($Prefix)) { + Write-Error "Error: -Prefix cannot be empty or whitespace" + exit 1 + } + $checkPrefix = $Prefix.TrimEnd('/') + if ($checkPrefix.Contains('/')) { + Write-Error "Error: -Prefix must be a single segment (no embedded slashes); e.g. 'feature', 'bugfix'" + exit 1 + } + $Prefix = "$checkPrefix/" +} + # Validate description is not empty after trimming (e.g., user passed only whitespace) if ([string]::IsNullOrWhiteSpace($featureDesc)) { Write-Error "Error: Feature description cannot be empty or contain only whitespace" @@ -74,6 +91,10 @@ function Get-HighestNumberFromNames { [long]$highest = 0 foreach ($name in $Names) { + # Strip optional prefix segment (e.g., "feature/003-name" -> "003-name") + if ($name -match '^[^/]+/([^/]+)$') { + $name = $Matches[1] + } if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') { [long]$num = 0 if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { @@ -242,7 +263,7 @@ if ($Timestamp -and $Number -ne 0) { # Determine branch prefix if ($Timestamp) { $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' - $branchName = "$featureNum-$branchSuffix" + $branchName = "$Prefix$featureNum-$branchSuffix" } else { # Determine branch number if ($Number -eq 0) { @@ -262,16 +283,19 @@ if ($Timestamp) { } $featureNum = ('{0:000}' -f $Number) - $branchName = "$featureNum-$branchSuffix" + $branchName = "$Prefix$featureNum-$branchSuffix" } +# Directory-safe name (no prefix slash) for specs/ paths +$featureDirName = "$featureNum-$branchSuffix" + # GitHub enforces a 244-byte limit on branch names # Validate and truncate if necessary $maxBranchLength = 244 if ($branchName.Length -gt $maxBranchLength) { # Calculate how much we need to trim from suffix # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 - $prefixLength = $featureNum.Length + 1 + $prefixLength = $Prefix.Length + $featureNum.Length + 1 $maxSuffixLength = $maxBranchLength - $prefixLength # Truncate suffix @@ -280,14 +304,15 @@ if ($branchName.Length -gt $maxBranchLength) { $truncatedSuffix = $truncatedSuffix -replace '-$', '' $originalBranchName = $branchName - $branchName = "$featureNum-$truncatedSuffix" + $branchName = "$Prefix$featureNum-$truncatedSuffix" + $featureDirName = "$featureNum-$truncatedSuffix" Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" } -$featureDir = Join-Path $specsDir $branchName +$featureDir = Join-Path $specsDir $featureDirName $specFile = Join-Path $featureDir 'spec.md' if (-not $DryRun) { @@ -357,7 +382,7 @@ if (-not $DryRun) { } # Set the SPECIFY_FEATURE environment variable for the current session - $env:SPECIFY_FEATURE = $branchName + $env:SPECIFY_FEATURE = $featureDirName } if ($Json) { @@ -377,6 +402,6 @@ if ($Json) { Write-Output "FEATURE_NUM: $featureNum" Write-Output "HAS_GIT: $hasGit" if (-not $DryRun) { - Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" + Write-Output "SPECIFY_FEATURE environment variable set to: $featureDirName" } } diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index c99f675081..13581c21dc 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -1321,3 +1321,368 @@ def test_plain_description_still_works(self, git_repo: Path): """Plain description without special characters continues to work.""" result = run_script(git_repo, "--dry-run", "--short-name", "feat", "Add login feature") assert result.returncode == 0, result.stderr + + +# ── Prefix (--prefix / -Prefix) Tests ───────────────────────────────────────── + + +def _parse_branch_from_stdout(stdout: str) -> str | None: + """Extract BRANCH_NAME value from plain-text script output.""" + for line in stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + return line.split(":", 1)[1].strip() + return None + + +@requires_bash +class TestPrefixBash: + """Tests for --prefix in core create-new-feature.sh.""" + + def test_prefix_creates_prefixed_branch(self, git_repo: Path): + """Branch name includes the prefix; git branch is created with it.""" + result = run_script(git_repo, "--prefix", "feature", "--short-name", "auth", "Add auth") + assert result.returncode == 0, result.stderr + branch = _parse_branch_from_stdout(result.stdout) + assert branch == "feature/001-auth", f"expected feature/001-auth, got: {branch}" + current = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=git_repo, capture_output=True, text=True, + ).stdout.strip() + assert current == "feature/001-auth" + + def test_prefix_spec_dir_has_no_prefix(self, git_repo: Path): + """Specs directory uses the flat name (no prefix slash).""" + result = run_script(git_repo, "--prefix", "feature", "--short-name", "auth", "Add auth") + assert result.returncode == 0, result.stderr + assert (git_repo / "specs" / "001-auth").is_dir() + assert (git_repo / "specs" / "001-auth" / "spec.md").exists() + # Nested dir must NOT exist + assert not (git_repo / "specs" / "feature").exists() + + def test_prefix_json_output(self, git_repo: Path): + """JSON: BRANCH_NAME has prefix, SPEC_FILE uses flat dir name.""" + result = run_script(git_repo, "--json", "--prefix", "bugfix", "--short-name", "fix", "Bug fix") + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "bugfix/001-fix" + assert "specs/001-fix/spec.md" in data["SPEC_FILE"] + assert "bugfix" not in data["SPEC_FILE"] + + def test_prefix_specify_feature_is_dir_name(self, git_repo: Path): + """SPECIFY_FEATURE hint uses the flat directory name (no prefix).""" + result = run_script(git_repo, "--prefix", "hotfix", "--short-name", "urgent", "Urgent fix") + assert result.returncode == 0, result.stderr + assert "SPECIFY_FEATURE=001-urgent" in result.stderr + assert "hotfix" not in result.stderr.split("SPECIFY_FEATURE=")[1].split("\n")[0] + + def test_prefix_with_timestamp(self, git_repo: Path): + """Timestamp mode: branch gets prefix, spec dir stays flat.""" + result = run_script( + git_repo, "--prefix", "feature", "--timestamp", "--short-name", "ts-feat", "TS feature" + ) + assert result.returncode == 0, result.stderr + branch = _parse_branch_from_stdout(result.stdout) + assert re.match(r"^feature/\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}" + # Spec dir must be flat + spec_dirs = [d.name for d in (git_repo / "specs").iterdir() if d.is_dir()] + assert len(spec_dirs) == 1 + assert re.match(r"^\d{8}-\d{6}-ts-feat$", spec_dirs[0]), f"unexpected spec dir: {spec_dirs[0]}" + + def test_prefix_truncation(self, git_repo: Path): + """Long suffix is truncated; branch and spec dir names are both valid.""" + long_name = "a-" * 150 + "end" + result = run_script( + git_repo, "--prefix", "feature", "--timestamp", "--short-name", long_name, "Long feature" + ) + assert result.returncode == 0, result.stderr + branch = _parse_branch_from_stdout(result.stdout) + assert branch is not None + assert len(branch) <= 244, f"branch too long: {len(branch)}" + assert branch.startswith("feature/") + # Spec dir must not contain slash + spec_dirs = [d.name for d in (git_repo / "specs").iterdir() if d.is_dir()] + assert len(spec_dirs) == 1 + assert "/" not in spec_dirs[0] + + def test_prefix_numbering_with_existing_prefixed_branches(self, git_repo: Path): + """Existing prefixed branches (e.g., feature/003-x) are counted for next number.""" + subprocess.run( + ["git", "checkout", "-b", "feature/003-existing"], + cwd=git_repo, check=True, capture_output=True, + ) + subprocess.run( + ["git", "checkout", "-"], + cwd=git_repo, check=True, capture_output=True, + ) + result = run_script(git_repo, "--prefix", "bugfix", "--short-name", "new", "New bugfix") + assert result.returncode == 0, result.stderr + branch = _parse_branch_from_stdout(result.stdout) + assert branch == "bugfix/004-new", f"expected bugfix/004-new, got: {branch}" + + def test_prefix_numbering_with_existing_unprefixed_specs(self, git_repo: Path): + """Existing flat spec dirs (e.g., 005-x) are counted when using prefix.""" + (git_repo / "specs" / "005-existing-spec").mkdir(parents=True) + result = run_script(git_repo, "--prefix", "feature", "--short-name", "new", "New feature") + assert result.returncode == 0, result.stderr + branch = _parse_branch_from_stdout(result.stdout) + assert branch == "feature/006-new", f"expected feature/006-new, got: {branch}" + + def test_prefix_numbering_with_existing_unprefixed_branches(self, git_repo: Path): + """Existing flat branches (e.g., 002-x) are counted when using prefix.""" + subprocess.run( + ["git", "checkout", "-b", "002-existing"], + cwd=git_repo, check=True, capture_output=True, + ) + subprocess.run( + ["git", "checkout", "-"], + cwd=git_repo, check=True, capture_output=True, + ) + result = run_script(git_repo, "--prefix", "feature", "--short-name", "next", "Next feature") + assert result.returncode == 0, result.stderr + branch = _parse_branch_from_stdout(result.stdout) + assert branch == "feature/003-next", f"expected feature/003-next, got: {branch}" + + def test_prefix_dry_run(self, git_repo: Path): + """Dry-run with prefix returns correct name without side effects.""" + result = run_script( + git_repo, "--dry-run", "--prefix", "feature", "--short-name", "dry", "Dry run" + ) + assert result.returncode == 0, result.stderr + branch = _parse_branch_from_stdout(result.stdout) + assert branch == "feature/001-dry", f"expected feature/001-dry, got: {branch}" + branches = subprocess.run( + ["git", "branch", "--list"], + cwd=git_repo, capture_output=True, text=True, + ) + assert "feature/001-dry" not in branches.stdout + assert not (git_repo / "specs").exists() + + def test_prefix_allow_existing_branch(self, git_repo: Path): + """--allow-existing-branch works with prefixed branch names.""" + subprocess.run( + ["git", "checkout", "-b", "feature/010-pre-exist"], + cwd=git_repo, check=True, capture_output=True, + ) + subprocess.run( + ["git", "checkout", "-"], + cwd=git_repo, check=True, capture_output=True, + ) + result = run_script( + git_repo, "--allow-existing-branch", "--prefix", "feature", + "--short-name", "pre-exist", "--number", "10", "Pre-existing", + ) + assert result.returncode == 0, result.stderr + current = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=git_repo, capture_output=True, text=True, + ).stdout.strip() + assert current == "feature/010-pre-exist" + + def test_prefix_trailing_slash_optional(self, git_repo: Path): + """Passing 'feature/' (with trailing slash) works identically to 'feature'.""" + result = run_script( + git_repo, "--prefix", "feature/", "--short-name", "slash", "Trailing slash" + ) + assert result.returncode == 0, result.stderr + branch = _parse_branch_from_stdout(result.stdout) + assert branch == "feature/001-slash", f"expected feature/001-slash, got: {branch}" + + def test_prefix_whitespace_trimmed(self, git_repo: Path): + """Whitespace around prefix value is trimmed.""" + result = run_script( + git_repo, "--prefix", " feature ", "--short-name", "ws", "Whitespace prefix" + ) + assert result.returncode == 0, result.stderr + branch = _parse_branch_from_stdout(result.stdout) + assert branch == "feature/001-ws", f"expected feature/001-ws, got: {branch}" + + def test_prefix_rejects_embedded_slash(self, git_repo: Path): + """Multi-segment prefix like 'feat/fix' is rejected.""" + result = run_script( + git_repo, "--dry-run", "--prefix", "feat/fix", "--short-name", "bad", "Bad prefix" + ) + assert result.returncode != 0 + assert "single segment" in result.stderr + + def test_prefix_rejects_whitespace_only(self, git_repo: Path): + """Whitespace-only prefix value is rejected.""" + result = run_script( + git_repo, "--dry-run", "--prefix", " ", "--short-name", "bad", "Bad prefix" + ) + assert result.returncode != 0 + assert "empty" in result.stderr.lower() or "whitespace" in result.stderr.lower() + + def test_no_prefix_still_unprefixed(self, git_repo: Path): + """Without --prefix, branch and spec dir are both flat (regression guard).""" + result = run_script(git_repo, "--short-name", "plain", "Plain feature") + assert result.returncode == 0, result.stderr + branch = _parse_branch_from_stdout(result.stdout) + assert branch == "001-plain" + assert (git_repo / "specs" / "001-plain").is_dir() + + def test_prefix_e2e_with_check_feature_branch(self, git_repo: Path): + """Full E2E: create prefixed branch, then validate with check_feature_branch.""" + run_script(git_repo, "--prefix", "feature", "--short-name", "e2e", "E2E prefix test") + branch = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=git_repo, capture_output=True, text=True, + ).stdout.strip() + assert branch == "feature/001-e2e" + assert (git_repo / "specs" / "001-e2e").is_dir() + val = source_and_call(f'check_feature_branch "{branch}" "true"') + assert val.returncode == 0, f"check_feature_branch rejected {branch}: {val.stderr}" + + def test_prefix_no_git(self, no_git_dir: Path): + """--prefix works without git (spec dir created, git warning emitted).""" + result = run_script( + no_git_dir, "--prefix", "feature", "--short-name", "no-git", "No git feature" + ) + assert result.returncode == 0, result.stderr + assert (no_git_dir / "specs" / "001-no-git").is_dir() + assert not (no_git_dir / "specs" / "feature").exists() + + +@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not available") +class TestPrefixPowerShell: + """Tests for -Prefix in core create-new-feature.ps1.""" + + def test_ps_prefix_creates_prefixed_branch(self, ps_git_repo: Path): + """PowerShell: branch name includes prefix.""" + result = run_ps_script( + ps_git_repo, "-Prefix", "feature", "-ShortName", "auth", "Add auth" + ) + assert result.returncode == 0, result.stderr + branch = _parse_branch_from_stdout(result.stdout) + assert branch == "feature/001-auth", f"expected feature/001-auth, got: {branch}" + + def test_ps_prefix_spec_dir_has_no_prefix(self, ps_git_repo: Path): + """PowerShell: spec dir uses flat name.""" + result = run_ps_script( + ps_git_repo, "-Prefix", "feature", "-ShortName", "auth", "Add auth" + ) + assert result.returncode == 0, result.stderr + assert (ps_git_repo / "specs" / "001-auth").is_dir() + assert not (ps_git_repo / "specs" / "feature").exists() + + def test_ps_prefix_json_output(self, ps_git_repo: Path): + """PowerShell: JSON BRANCH_NAME has prefix, SPEC_FILE is flat.""" + result = run_ps_script( + ps_git_repo, "-Json", "-Prefix", "bugfix", "-ShortName", "fix", "Bug fix" + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "bugfix/001-fix" + assert "specs/001-fix/spec.md" in data["SPEC_FILE"] + assert "bugfix" not in data["SPEC_FILE"] + + def test_ps_prefix_with_timestamp(self, ps_git_repo: Path): + """PowerShell: timestamp + prefix produces prefixed branch, flat spec dir.""" + result = run_ps_script( + ps_git_repo, "-Prefix", "feature", "-Timestamp", "-ShortName", "ts", "TS feat" + ) + assert result.returncode == 0, result.stderr + branch = _parse_branch_from_stdout(result.stdout) + assert re.match(r"^feature/\d{8}-\d{6}-ts$", branch), f"unexpected: {branch}" + + def test_ps_prefix_numbering_with_existing_specs(self, ps_git_repo: Path): + """PowerShell: existing spec dirs counted for next number with prefix.""" + (ps_git_repo / "specs" / "005-existing").mkdir(parents=True) + result = run_ps_script( + ps_git_repo, "-Prefix", "feature", "-ShortName", "new", "New feature" + ) + assert result.returncode == 0, result.stderr + branch = _parse_branch_from_stdout(result.stdout) + assert branch == "feature/006-new", f"expected feature/006-new, got: {branch}" + + def test_ps_prefix_dry_run(self, ps_git_repo: Path): + """PowerShell: dry-run with prefix returns correct name.""" + result = run_ps_script( + ps_git_repo, "-DryRun", "-Prefix", "feature", "-ShortName", "dry", "Dry run" + ) + assert result.returncode == 0, result.stderr + branch = _parse_branch_from_stdout(result.stdout) + assert branch == "feature/001-dry", f"expected feature/001-dry, got: {branch}" + + def test_ps_prefix_rejects_embedded_slash(self, ps_git_repo: Path): + """PowerShell: multi-segment prefix is rejected.""" + result = run_ps_script( + ps_git_repo, "-DryRun", "-Prefix", "feat/fix", "-ShortName", "bad", "Bad" + ) + assert result.returncode != 0 + assert "single segment" in result.stderr.lower() or "single segment" in result.stdout.lower() + + def test_ps_prefix_trailing_slash_optional(self, ps_git_repo: Path): + """PowerShell: trailing slash is normalized.""" + result = run_ps_script( + ps_git_repo, "-Prefix", "feature/", "-ShortName", "slash", "Trailing slash" + ) + assert result.returncode == 0, result.stderr + branch = _parse_branch_from_stdout(result.stdout) + assert branch == "feature/001-slash" + + +@requires_bash +class TestPrefixExtensionBash: + """Tests for --prefix in extension create-new-feature.sh (git-only, no spec dirs).""" + + def _run_ext(self, ext_git_repo: Path, *args: str): + script = ( + ext_git_repo + / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" + ) + return subprocess.run( + ["bash", str(script), *args], + cwd=ext_git_repo, capture_output=True, text=True, + ) + + def test_ext_prefix_creates_prefixed_branch(self, ext_git_repo: Path): + """Extension script creates prefixed branch.""" + result = self._run_ext(ext_git_repo, "--prefix", "feature", "--short-name", "ext", "Ext feature") + assert result.returncode == 0, result.stderr + branch = _parse_branch_from_stdout(result.stdout) + assert branch == "feature/001-ext", f"expected feature/001-ext, got: {branch}" + + def test_ext_prefix_numbering_strips_prefix_from_existing(self, ext_git_repo: Path): + """Extension numbering strips prefix from existing branches (e.g., feature/003-x -> 003).""" + subprocess.run( + ["git", "checkout", "-b", "feature/003-existing"], + cwd=ext_git_repo, check=True, capture_output=True, + ) + subprocess.run( + ["git", "checkout", "-"], + cwd=ext_git_repo, check=True, capture_output=True, + ) + result = self._run_ext(ext_git_repo, "--prefix", "bugfix", "--short-name", "next", "Next") + assert result.returncode == 0, result.stderr + branch = _parse_branch_from_stdout(result.stdout) + assert branch == "bugfix/004-next", f"expected bugfix/004-next, got: {branch}" + + def test_ext_prefix_json_no_spec_file(self, ext_git_repo: Path): + """Extension JSON does NOT include SPEC_FILE (git-only).""" + result = self._run_ext(ext_git_repo, "--json", "--prefix", "feature", "--short-name", "jsn", "JSON") + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "feature/001-jsn" + assert "SPEC_FILE" not in data + + def test_ext_prefix_dry_run(self, ext_git_repo: Path): + """Extension dry-run with prefix returns correct name.""" + result = self._run_ext( + ext_git_repo, "--dry-run", "--prefix", "feature", "--short-name", "dry", "Dry" + ) + assert result.returncode == 0, result.stderr + branch = _parse_branch_from_stdout(result.stdout) + assert branch == "feature/001-dry" + branches = subprocess.run( + ["git", "branch", "--list"], + cwd=ext_git_repo, capture_output=True, text=True, + ) + assert "feature/001-dry" not in branches.stdout + + def test_ext_prefix_rejects_embedded_slash(self, ext_git_repo: Path): + """Extension rejects multi-segment prefix.""" + result = self._run_ext( + ext_git_repo, "--dry-run", "--prefix", "feat/fix", "--short-name", "bad", "Bad" + ) + assert result.returncode != 0 + assert "single segment" in result.stderr