From a43f73c1ca3a536c17d2a4e28e0aad7d9e270484 Mon Sep 17 00:00:00 2001 From: leoxiao2012 Date: Wed, 13 May 2026 17:59:53 +0800 Subject: [PATCH 1/6] feat(git): add --branch-prefix option to create-new-feature scripts Support custom branch prefixes (e.g. feature/, bugfix/, hotfix/) in auto-generated git branches. The agent chooses the prefix based on context and passes --branch-prefix to the script. Number extraction now handles prefixed branch names correctly. Co-Authored-By: Claude Opus 4.6 --- extensions/git/README.md | 12 ++++++- .../git/commands/speckit.git.feature.md | 21 +++++++++--- .../git/scripts/bash/create-new-feature.sh | 31 ++++++++++++++---- .../scripts/powershell/create-new-feature.ps1 | 16 +++++++--- scripts/bash/create-new-feature.sh | 32 +++++++++++++++---- scripts/powershell/create-new-feature.ps1 | 16 +++++++--- 6 files changed, 101 insertions(+), 27 deletions(-) diff --git a/extensions/git/README.md b/extensions/git/README.md index 31ba75c30f..1ce0612790 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 `--branch-prefix` (Bash) / `-BranchPrefix` (PowerShell) option to prepend a custom prefix to branch names. Common prefixes: + +- `feature/` — new features or enhancements +- `bugfix/` — bug fixes +- `hotfix/` — urgent production fixes + +Example: `--branch-prefix "feature/"` produces `feature/001-user-auth` instead of `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..d1a8386a27 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 `--branch-prefix`) for the default flat naming + +Choose the most appropriate prefix based on the feature description and context. The prefix is prepended to the generated branch name (e.g., `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 --branch-prefix "" --short-name "" ""` +- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --branch-prefix "" --timestamp --short-name "" ""` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -BranchPrefix "" -ShortName "" ""` +- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -BranchPrefix "" -Timestamp -ShortName "" ""` + +If no prefix is needed, omit `--branch-prefix` / `-BranchPrefix` 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..9222da1b8d 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 ;; + --branch-prefix) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --branch-prefix requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --branch-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] [--branch-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 " --branch-prefix Custom prefix for the branch name (e.g. 'feature/', 'bugfix/')" echo " --help, -h Show this help message" echo "" echo "Environment variables:" @@ -90,7 +105,7 @@ done 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] [--branch-prefix ] " >&2 exit 1 fi @@ -134,6 +149,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 +353,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 +370,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 +382,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..d9220d7e46 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]$BranchPrefix = "", [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 ] [-BranchPrefix ] [-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 " -BranchPrefix 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" @@ -70,6 +72,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 +295,7 @@ if ($env:GIT_BRANCH_NAME) { if ($Timestamp) { $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' - $branchName = "$featureNum-$branchSuffix" + $branchName = "$BranchPrefix$featureNum-$branchSuffix" } else { if ($Number -eq 0) { if ($DryRun -and $hasGit) { @@ -304,20 +310,20 @@ if ($env:GIT_BRANCH_NAME) { } $featureNum = ('{0:000}' -f $Number) - $branchName = "$featureNum-$branchSuffix" + $branchName = "$BranchPrefix$featureNum-$branchSuffix" } } $maxBranchLength = 244 if ($branchName.Length -gt $maxBranchLength) { - $prefixLength = $featureNum.Length + 1 + $prefixLength = $BranchPrefix.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 = "$BranchPrefix$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..16e190a520 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 ;; + --branch-prefix) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --branch-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: --branch-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] [--branch-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 " --branch-prefix Custom prefix for the branch name (e.g. 'feature/', 'bugfix/')" echo " --help, -h Show this help message" echo "" echo "Examples:" @@ -79,7 +95,7 @@ done 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] [--branch-prefix ] " >&2 exit 1 fi @@ -124,6 +140,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 +294,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,7 +317,7 @@ 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 # GitHub enforces a 244-byte limit on branch names @@ -306,7 +326,7 @@ 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 @@ -315,7 +335,7 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then 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/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 2f23283fc4..96a8f5804b 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]$BranchPrefix = "", [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 ] [-BranchPrefix ] [-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 " -BranchPrefix 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" @@ -74,6 +76,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 +248,7 @@ if ($Timestamp -and $Number -ne 0) { # Determine branch prefix if ($Timestamp) { $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' - $branchName = "$featureNum-$branchSuffix" + $branchName = "$BranchPrefix$featureNum-$branchSuffix" } else { # Determine branch number if ($Number -eq 0) { @@ -262,7 +268,7 @@ if ($Timestamp) { } $featureNum = ('{0:000}' -f $Number) - $branchName = "$featureNum-$branchSuffix" + $branchName = "$BranchPrefix$featureNum-$branchSuffix" } # GitHub enforces a 244-byte limit on branch names @@ -271,7 +277,7 @@ $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 = $BranchPrefix.Length + $featureNum.Length + 1 $maxSuffixLength = $maxBranchLength - $prefixLength # Truncate suffix @@ -280,7 +286,7 @@ if ($branchName.Length -gt $maxBranchLength) { $truncatedSuffix = $truncatedSuffix -replace '-$', '' $originalBranchName = $branchName - $branchName = "$featureNum-$truncatedSuffix" + $branchName = "$BranchPrefix$featureNum-$truncatedSuffix" Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" From 46e42dc41b8f19c6e9268900b1a5dd83a84c77da Mon Sep 17 00:00:00 2001 From: leoxiao2012 Date: Wed, 13 May 2026 18:10:40 +0800 Subject: [PATCH 2/6] fix(git): auto-append '/' to branch prefix if missing Ensures consistent {prefix}/{number}-{suffix} format even when the user omits the trailing slash (e.g. --branch-prefix "feature" becomes "feature/"). Co-Authored-By: Claude Opus 4.6 --- extensions/git/scripts/bash/create-new-feature.sh | 5 +++++ extensions/git/scripts/powershell/create-new-feature.ps1 | 5 +++++ scripts/bash/create-new-feature.sh | 5 +++++ scripts/powershell/create-new-feature.ps1 | 5 +++++ 4 files changed, 20 insertions(+) diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh index 9222da1b8d..560c3a4856 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -103,6 +103,11 @@ while [ $i -le $# ]; do i=$((i + 1)) done +# Auto-append '/' if branch prefix is non-empty and doesn't end with '/' +if [ -n "$BRANCH_PREFIX" ] && [[ ! "$BRANCH_PREFIX" =~ /$ ]]; then + BRANCH_PREFIX="$BRANCH_PREFIX/" +fi + FEATURE_DESCRIPTION="${ARGS[*]}" if [ -z "$FEATURE_DESCRIPTION" ]; then echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] [--branch-prefix ] " >&2 diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 index d9220d7e46..1808a1e167 100644 --- a/extensions/git/scripts/powershell/create-new-feature.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -45,6 +45,11 @@ if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { $featureDesc = ($FeatureDescription -join ' ').Trim() +# Auto-append '/' if branch prefix is non-empty and doesn't end with '/' +if ($BranchPrefix -and -not $BranchPrefix.EndsWith('/')) { + $BranchPrefix = "$BranchPrefix/" +} + if ([string]::IsNullOrWhiteSpace($featureDesc)) { Write-Error "Error: Feature description cannot be empty or contain only whitespace" exit 1 diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 16e190a520..b0670d3945 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -93,6 +93,11 @@ while [ $i -le $# ]; do i=$((i + 1)) done +# Auto-append '/' if branch prefix is non-empty and doesn't end with '/' +if [ -n "$BRANCH_PREFIX" ] && [[ ! "$BRANCH_PREFIX" =~ /$ ]]; then + BRANCH_PREFIX="$BRANCH_PREFIX/" +fi + FEATURE_DESCRIPTION="${ARGS[*]}" if [ -z "$FEATURE_DESCRIPTION" ]; then echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] [--branch-prefix ] " >&2 diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 96a8f5804b..188f300996 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -45,6 +45,11 @@ if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { $featureDesc = ($FeatureDescription -join ' ').Trim() +# Auto-append '/' if branch prefix is non-empty and doesn't end with '/' +if ($BranchPrefix -and -not $BranchPrefix.EndsWith('/')) { + $BranchPrefix = "$BranchPrefix/" +} + # 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" From 328475e7b340acbc646a34c25b3e5ce69166da93 Mon Sep 17 00:00:00 2001 From: leoxiao2012 Date: Wed, 13 May 2026 18:12:44 +0800 Subject: [PATCH 3/6] =?UTF-8?q?docs(git):=20update=20branch=20prefix=20doc?= =?UTF-8?q?s=20=E2=80=94=20trailing=20slash=20is=20optional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update command file and README to show prefixes without trailing slash and explain the auto-append behavior and final format. Co-Authored-By: Claude Opus 4.6 --- extensions/git/README.md | 10 +++++----- extensions/git/commands/speckit.git.feature.md | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/extensions/git/README.md b/extensions/git/README.md index 1ce0612790..8ca0975aac 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, with optional branch prefix (e.g., `feature/`, `bugfix/`) +- **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) @@ -69,11 +69,11 @@ auto_commit: The `create-new-feature` scripts accept a `--branch-prefix` (Bash) / `-BranchPrefix` (PowerShell) option to prepend a custom prefix to branch names. Common prefixes: -- `feature/` — new features or enhancements -- `bugfix/` — bug fixes -- `hotfix/` — urgent production fixes +- `feature` — new features or enhancements +- `bugfix` — bug fixes +- `hotfix` — urgent production fixes -Example: `--branch-prefix "feature/"` produces `feature/001-user-auth` instead of `001-user-auth`. +The trailing `/` is optional — the script auto-appends it if missing. The final branch name format is `{prefix}/{number}-{short-name}` (e.g., `--branch-prefix "feature"` produces `feature/001-user-auth`). ## Installation diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md index d1a8386a27..a86edc7e41 100644 --- a/extensions/git/commands/speckit.git.feature.md +++ b/extensions/git/commands/speckit.git.feature.md @@ -38,12 +38,12 @@ Determine the branch numbering strategy by checking configuration in this order: 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 `feature` for new features or enhancements +- Use `bugfix` for bug fixes +- Use `hotfix` for urgent production fixes - Use no prefix (omit `--branch-prefix`) for the default flat naming -Choose the most appropriate prefix based on the feature description and context. The prefix is prepended to the generated branch name (e.g., `feature/001-user-auth`). +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., `--branch-prefix "feature"` produces `feature/001-user-auth`). ## Execution From 6c61bffcd6ca79ffc69c765d621050ca6efdb091 Mon Sep 17 00:00:00 2001 From: leoxiao2012 Date: Wed, 13 May 2026 18:16:25 +0800 Subject: [PATCH 4/6] refactor(git): rename --branch-prefix to --prefix Shorter flag name consistent with --short-name and --number style. Co-Authored-By: Claude Opus 4.6 --- extensions/git/README.md | 4 ++-- extensions/git/commands/speckit.git.feature.md | 14 +++++++------- .../git/scripts/bash/create-new-feature.sh | 10 +++++----- .../scripts/powershell/create-new-feature.ps1 | 18 +++++++++--------- scripts/bash/create-new-feature.sh | 12 ++++++------ scripts/powershell/create-new-feature.ps1 | 18 +++++++++--------- 6 files changed, 38 insertions(+), 38 deletions(-) diff --git a/extensions/git/README.md b/extensions/git/README.md index 8ca0975aac..5cfa8278da 100644 --- a/extensions/git/README.md +++ b/extensions/git/README.md @@ -67,13 +67,13 @@ auto_commit: ### Branch Prefix -The `create-new-feature` scripts accept a `--branch-prefix` (Bash) / `-BranchPrefix` (PowerShell) option to prepend a custom prefix to branch names. Common prefixes: +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., `--branch-prefix "feature"` produces `feature/001-user-auth`). +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 diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md index a86edc7e41..235c9723ef 100644 --- a/extensions/git/commands/speckit.git.feature.md +++ b/extensions/git/commands/speckit.git.feature.md @@ -41,9 +41,9 @@ 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 `--branch-prefix`) for the default flat naming +- 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., `--branch-prefix "feature"` produces `feature/001-user-auth`). +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 @@ -54,12 +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 --branch-prefix "" --short-name "" ""` -- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --branch-prefix "" --timestamp --short-name "" ""` -- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -BranchPrefix "" -ShortName "" ""` -- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -BranchPrefix "" -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 `--branch-prefix` / `-BranchPrefix` entirely. +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 560c3a4856..2a5dfd6386 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -60,21 +60,21 @@ while [ $i -le $# ]; do --timestamp) USE_TIMESTAMP=true ;; - --branch-prefix) + --prefix) if [ $((i + 1)) -gt $# ]; then - echo 'Error: --branch-prefix requires a value' >&2 + echo 'Error: --prefix requires a value' >&2 exit 1 fi i=$((i + 1)) next_arg="${!i}" if [[ "$next_arg" == --* ]]; then - echo 'Error: --branch-prefix requires a value' >&2 + 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] [--branch-prefix ] " + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] [--prefix ] " echo "" echo "Options:" echo " --json Output in JSON format" @@ -83,7 +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 " --branch-prefix Custom prefix for the branch name (e.g. 'feature/', 'bugfix/')" + echo " --prefix Custom prefix for the branch name (e.g. 'feature', 'bugfix')" echo " --help, -h Show this help message" echo "" echo "Environment variables:" diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 index 1808a1e167..99493b9d92 100644 --- a/extensions/git/scripts/powershell/create-new-feature.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -9,7 +9,7 @@ param( [switch]$AllowExistingBranch, [switch]$DryRun, [string]$ShortName, - [string]$BranchPrefix = "", + [string]$Prefix = "", [Parameter()] [long]$Number = 0, [switch]$Timestamp, @@ -20,14 +20,14 @@ param( $ErrorActionPreference = 'Stop' if ($Help) { - Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-BranchPrefix ] [-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 " -BranchPrefix Custom prefix for the branch name (e.g. 'feature/', 'bugfix/')" + 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" @@ -46,8 +46,8 @@ if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { $featureDesc = ($FeatureDescription -join ' ').Trim() # Auto-append '/' if branch prefix is non-empty and doesn't end with '/' -if ($BranchPrefix -and -not $BranchPrefix.EndsWith('/')) { - $BranchPrefix = "$BranchPrefix/" +if ($Prefix -and -not $Prefix.EndsWith('/')) { + $Prefix = "$Prefix/" } if ([string]::IsNullOrWhiteSpace($featureDesc)) { @@ -300,7 +300,7 @@ if ($env:GIT_BRANCH_NAME) { if ($Timestamp) { $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' - $branchName = "$BranchPrefix$featureNum-$branchSuffix" + $branchName = "$Prefix$featureNum-$branchSuffix" } else { if ($Number -eq 0) { if ($DryRun -and $hasGit) { @@ -315,20 +315,20 @@ if ($env:GIT_BRANCH_NAME) { } $featureNum = ('{0:000}' -f $Number) - $branchName = "$BranchPrefix$featureNum-$branchSuffix" + $branchName = "$Prefix$featureNum-$branchSuffix" } } $maxBranchLength = 244 if ($branchName.Length -gt $maxBranchLength) { - $prefixLength = $BranchPrefix.Length + $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 = "$BranchPrefix$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 b0670d3945..f9ee0dc327 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -53,22 +53,22 @@ while [ $i -le $# ]; do --timestamp) USE_TIMESTAMP=true ;; - --branch-prefix) + --prefix) if [ $((i + 1)) -gt $# ]; then - echo 'Error: --branch-prefix requires a value' >&2 + 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: --branch-prefix requires a value' >&2 + 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] [--branch-prefix ] " + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] [--prefix ] " echo "" echo "Options:" echo " --json Output in JSON format" @@ -77,7 +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 " --branch-prefix Custom prefix for the branch name (e.g. 'feature/', 'bugfix/')" + echo " --prefix Custom prefix for the branch name (e.g. 'feature', 'bugfix')" echo " --help, -h Show this help message" echo "" echo "Examples:" @@ -100,7 +100,7 @@ fi FEATURE_DESCRIPTION="${ARGS[*]}" if [ -z "$FEATURE_DESCRIPTION" ]; then - echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] [--branch-prefix ] " >&2 + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] [--prefix ] " >&2 exit 1 fi diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 188f300996..5d9748bedc 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -6,7 +6,7 @@ param( [switch]$AllowExistingBranch, [switch]$DryRun, [string]$ShortName, - [string]$BranchPrefix = "", + [string]$Prefix = "", [Parameter()] [long]$Number = 0, [switch]$Timestamp, @@ -18,14 +18,14 @@ $ErrorActionPreference = 'Stop' # Show help if requested if ($Help) { - Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-BranchPrefix ] [-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 " -BranchPrefix Custom prefix for the branch name (e.g. 'feature/', 'bugfix/')" + 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" @@ -46,8 +46,8 @@ if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { $featureDesc = ($FeatureDescription -join ' ').Trim() # Auto-append '/' if branch prefix is non-empty and doesn't end with '/' -if ($BranchPrefix -and -not $BranchPrefix.EndsWith('/')) { - $BranchPrefix = "$BranchPrefix/" +if ($Prefix -and -not $Prefix.EndsWith('/')) { + $Prefix = "$Prefix/" } # Validate description is not empty after trimming (e.g., user passed only whitespace) @@ -253,7 +253,7 @@ if ($Timestamp -and $Number -ne 0) { # Determine branch prefix if ($Timestamp) { $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' - $branchName = "$BranchPrefix$featureNum-$branchSuffix" + $branchName = "$Prefix$featureNum-$branchSuffix" } else { # Determine branch number if ($Number -eq 0) { @@ -273,7 +273,7 @@ if ($Timestamp) { } $featureNum = ('{0:000}' -f $Number) - $branchName = "$BranchPrefix$featureNum-$branchSuffix" + $branchName = "$Prefix$featureNum-$branchSuffix" } # GitHub enforces a 244-byte limit on branch names @@ -282,7 +282,7 @@ $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 = $BranchPrefix.Length + $featureNum.Length + 1 + $prefixLength = $Prefix.Length + $featureNum.Length + 1 $maxSuffixLength = $maxBranchLength - $prefixLength # Truncate suffix @@ -291,7 +291,7 @@ if ($branchName.Length -gt $maxBranchLength) { $truncatedSuffix = $truncatedSuffix -replace '-$', '' $originalBranchName = $branchName - $branchName = "$BranchPrefix$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)" From 55ff21fff682177a30b8cd94cda262f79f96f918 Mon Sep 17 00:00:00 2001 From: leoxiao2012 Date: Wed, 13 May 2026 21:30:13 +0800 Subject: [PATCH 5/6] fix(git): add missing --prefix/-Prefix in error usage strings The rename from --branch-prefix to --prefix missed the error-path usage messages in three scripts. Co-Authored-By: Claude Opus 4.6 --- extensions/git/scripts/bash/create-new-feature.sh | 2 +- extensions/git/scripts/powershell/create-new-feature.ps1 | 2 +- scripts/powershell/create-new-feature.ps1 | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh index 2a5dfd6386..7ef78161eb 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -110,7 +110,7 @@ fi FEATURE_DESCRIPTION="${ARGS[*]}" if [ -z "$FEATURE_DESCRIPTION" ]; then - echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] [--branch-prefix ] " >&2 + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] [--prefix ] " >&2 exit 1 fi diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 index 99493b9d92..5e38791c58 100644 --- a/extensions/git/scripts/powershell/create-new-feature.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -39,7 +39,7 @@ 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 } diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 5d9748bedc..c040a7591a 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -39,7 +39,7 @@ 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 } From c76d3de12667bccaa6e2f2001e23f0026b8dccaa Mon Sep 17 00:00:00 2001 From: leoxiao2012 Date: Thu, 14 May 2026 14:28:01 +0800 Subject: [PATCH 6/6] fix(git): keep specs dir flat when --prefix contains slashes Separate BRANCH_NAME (git ref, may include prefix like feature/) from FEATURE_DIR_NAME (directory-safe, no prefix) so that specs/ paths and sequential-number detection remain correct when a branch prefix is used. Also adds prefix validation (reject embedded slashes, trim whitespace) and 31 new pytest tests covering --prefix behavior across bash, ps1, and extension scripts. Co-Authored-By: Claude Opus 4.6 --- .../git/scripts/bash/create-new-feature.sh | 17 +- .../scripts/powershell/create-new-feature.ps1 | 16 +- scripts/bash/create-new-feature.sh | 33 +- scripts/powershell/create-new-feature.ps1 | 26 +- tests/test_timestamp_branches.py | 365 ++++++++++++++++++ 5 files changed, 436 insertions(+), 21 deletions(-) diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh index 7ef78161eb..19881dbe08 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -103,9 +103,20 @@ while [ $i -le $# ]; do i=$((i + 1)) done -# Auto-append '/' if branch prefix is non-empty and doesn't end with '/' -if [ -n "$BRANCH_PREFIX" ] && [[ ! "$BRANCH_PREFIX" =~ /$ ]]; then - BRANCH_PREFIX="$BRANCH_PREFIX/" +# 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[*]}" diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 index 5e38791c58..e454b7fef5 100644 --- a/extensions/git/scripts/powershell/create-new-feature.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -45,9 +45,19 @@ if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { $featureDesc = ($FeatureDescription -join ' ').Trim() -# Auto-append '/' if branch prefix is non-empty and doesn't end with '/' -if ($Prefix -and -not $Prefix.EndsWith('/')) { - $Prefix = "$Prefix/" +# 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)) { diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index f9ee0dc327..1f24d2dfa3 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -93,9 +93,20 @@ while [ $i -le $# ]; do i=$((i + 1)) done -# Auto-append '/' if branch prefix is non-empty and doesn't end with '/' -if [ -n "$BRANCH_PREFIX" ] && [[ ! "$BRANCH_PREFIX" =~ /$ ]]; then - BRANCH_PREFIX="$BRANCH_PREFIX/" +# 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[*]}" @@ -325,6 +336,9 @@ else 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 @@ -333,21 +347,22 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 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="${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 @@ -403,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 @@ -433,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 c040a7591a..af181fabd9 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -45,9 +45,19 @@ if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { $featureDesc = ($FeatureDescription -join ' ').Trim() -# Auto-append '/' if branch prefix is non-empty and doesn't end with '/' -if ($Prefix -and -not $Prefix.EndsWith('/')) { - $Prefix = "$Prefix/" +# 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) @@ -276,6 +286,9 @@ if ($Timestamp) { $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 @@ -292,13 +305,14 @@ if ($branchName.Length -gt $maxBranchLength) { $originalBranchName = $branchName $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) { @@ -368,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) { @@ -388,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