From 1f3d9b5fdd41f99851bfbfc2f719c8c14a6626d9 Mon Sep 17 00:00:00 2001 From: Simon Gent Date: Thu, 23 Oct 2025 12:14:48 +0100 Subject: [PATCH] feat: check remote branches to prevent duplicate branch numbers - Add --number parameter to create-new-feature scripts (bash & PowerShell) - Add check_existing_branches() function to fetch and scan remote branches - Update branch numbering logic to check remotes before creating new branches - Update /speckit.specify command to document remote branch checking workflow - Prevents duplicate branch numbers when branches exist on remotes but not locally - Maintains backward compatibility with existing workflows - Falls back to local directory scanning when Git is not available --- scripts/bash/create-new-feature.sh | 79 ++++++++++++++++++----- scripts/powershell/create-new-feature.ps1 | 76 ++++++++++++++++++---- templates/commands/specify.md | 34 +++++++--- 3 files changed, 151 insertions(+), 38 deletions(-) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 53adbcef..714927f2 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -4,6 +4,7 @@ set -e JSON_MODE=false SHORT_NAME="" +BRANCH_NUMBER="" ARGS=() i=1 while [ $i -le $# ]; do @@ -26,17 +27,31 @@ while [ $i -le $# ]; do fi SHORT_NAME="$next_arg" ;; + --number) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + BRANCH_NUMBER="$next_arg" + ;; --help|-h) - echo "Usage: $0 [--json] [--short-name ] " + echo "Usage: $0 [--json] [--short-name ] [--number N] " echo "" echo "Options:" echo " --json Output in JSON format" 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 " --help, -h Show this help message" echo "" echo "Examples:" echo " $0 'Add user authentication system' --short-name 'user-auth'" - echo " $0 'Implement OAuth2 integration for API'" + echo " $0 'Implement OAuth2 integration for API' --number 5" exit 0 ;; *) @@ -48,7 +63,7 @@ done FEATURE_DESCRIPTION="${ARGS[*]}" if [ -z "$FEATURE_DESCRIPTION" ]; then - echo "Usage: $0 [--json] [--short-name ] " >&2 + echo "Usage: $0 [--json] [--short-name ] [--number N] " >&2 exit 1 fi @@ -65,6 +80,28 @@ find_repo_root() { return 1 } +# Function to check existing branches (local and remote) and return next available number +check_existing_branches() { + local short_name="$1" + + # Fetch all remotes to get latest branch info (suppress errors if no remotes) + git fetch --all --prune 2>/dev/null || true + + # Find all branches matching the pattern (local and remote) + local branches=$(git branch -a 2>/dev/null | grep -E "feature/[0-9]+-${short_name}$" | sed 's/.*feature\///' | sed "s/-${short_name}$//" | sort -n) + + # Get the highest number + local max_num=0 + for num in $branches; do + if [ "$num" -gt "$max_num" ]; then + max_num=$num + fi + done + + # Return next number + echo $((max_num + 1)) +} + # Resolve repository root. Prefer git information when available, but fall back # to searching for repository markers so the workflow still functions in repositories that # were initialised with --no-git. @@ -87,20 +124,6 @@ cd "$REPO_ROOT" SPECS_DIR="$REPO_ROOT/specs" mkdir -p "$SPECS_DIR" -HIGHEST=0 -if [ -d "$SPECS_DIR" ]; then - for dir in "$SPECS_DIR"/*; do - [ -d "$dir" ] || continue - dirname=$(basename "$dir") - number=$(echo "$dirname" | grep -o '^[0-9]\+' || echo "0") - number=$((10#$number)) - if [ "$number" -gt "$HIGHEST" ]; then HIGHEST=$number; fi - done -fi - -NEXT=$((HIGHEST + 1)) -FEATURE_NUM=$(printf "%03d" "$NEXT") - # Function to generate branch name with stop word filtering and length filtering generate_branch_name() { local description="$1" @@ -157,6 +180,28 @@ else BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") fi +# Determine branch number +if [ -z "$BRANCH_NUMBER" ]; then + if [ "$HAS_GIT" = true ]; then + # Check existing branches on remotes + BRANCH_NUMBER=$(check_existing_branches "$BRANCH_SUFFIX") + else + # Fall back to local directory check + HIGHEST=0 + if [ -d "$SPECS_DIR" ]; then + for dir in "$SPECS_DIR"/*; do + [ -d "$dir" ] || continue + dirname=$(basename "$dir") + number=$(echo "$dirname" | grep -o '^[0-9]\+' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$HIGHEST" ]; then HIGHEST=$number; fi + done + fi + BRANCH_NUMBER=$((HIGHEST + 1)) + fi +fi + +FEATURE_NUM=$(printf "%03d" "$BRANCH_NUMBER") BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" # GitHub enforces a 244-byte limit on branch names diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 83e286ac..f3754725 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -4,6 +4,7 @@ param( [switch]$Json, [string]$ShortName, + [int]$Number = 0, [switch]$Help, [Parameter(ValueFromRemainingArguments = $true)] [string[]]$FeatureDescription @@ -12,11 +13,12 @@ $ErrorActionPreference = 'Stop' # Show help if requested if ($Help) { - Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] " + Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] [-Number N] " Write-Host "" Write-Host "Options:" Write-Host " -Json Output in JSON format" Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" + Write-Host " -Number N Specify branch number manually (overrides auto-detection)" Write-Host " -Help Show this help message" Write-Host "" Write-Host "Examples:" @@ -56,6 +58,45 @@ function Find-RepositoryRoot { $current = $parent } } + +function Get-NextBranchNumber { + param( + [string]$ShortName + ) + + # Fetch all remotes to get latest branch info (suppress errors if no remotes) + try { + git fetch --all --prune 2>$null | Out-Null + } catch { + # Ignore fetch errors + } + + # Find all branches matching the pattern (local and remote) + $branches = @() + try { + $allBranches = git branch -a 2>$null + if ($allBranches) { + $branches = $allBranches | Where-Object { $_ -match "feature/(\d+)-$([regex]::Escape($ShortName))$" } | ForEach-Object { + if ($_ -match "feature/(\d+)-") { + [int]$matches[1] + } + } + } + } catch { + # Ignore errors + } + + # Get the highest number + $maxNum = 0 + foreach ($num in $branches) { + if ($num -gt $maxNum) { + $maxNum = $num + } + } + + # Return next number + return $maxNum + 1 +} $fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot) if (-not $fallbackRoot) { Write-Error "Error: Could not determine repository root. Please run this script from within the repository." @@ -79,18 +120,6 @@ Set-Location $repoRoot $specsDir = Join-Path $repoRoot 'specs' New-Item -ItemType Directory -Path $specsDir -Force | Out-Null -$highest = 0 -if (Test-Path $specsDir) { - Get-ChildItem -Path $specsDir -Directory | ForEach-Object { - if ($_.Name -match '^(\d{3})') { - $num = [int]$matches[1] - if ($num -gt $highest) { $highest = $num } - } - } -} -$next = $highest + 1 -$featureNum = ('{0:000}' -f $next) - # Function to generate branch name with stop word filtering and length filtering function Get-BranchName { param([string]$Description) @@ -145,6 +174,27 @@ if ($ShortName) { $branchSuffix = Get-BranchName -Description $featureDesc } +# Determine branch number +if ($Number -eq 0) { + if ($hasGit) { + # Check existing branches on remotes + $Number = Get-NextBranchNumber -ShortName $branchSuffix + } else { + # Fall back to local directory check + $highest = 0 + if (Test-Path $specsDir) { + Get-ChildItem -Path $specsDir -Directory | ForEach-Object { + if ($_.Name -match '^(\d{3})') { + $num = [int]$matches[1] + if ($num -gt $highest) { $highest = $num } + } + } + } + $Number = $highest + 1 + } +} + +$featureNum = ('{0:000}' -f $Number) $branchName = "$featureNum-$branchSuffix" # GitHub enforces a 244-byte limit on branch names diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 03f681e5..c3e6b37c 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -31,16 +31,34 @@ Given that feature description, do this: - "Create a dashboard for analytics" → "analytics-dashboard" - "Fix payment processing timeout bug" → "fix-payment-timeout" -2. Run the script `{SCRIPT}` from repo root **with the short-name argument** and parse its JSON output for BRANCH_NAME and SPEC_FILE. All file paths must be absolute. - +2. **Check for existing branches before creating new one**: + + a. First, fetch all remote branches to ensure we have the latest information: + ```bash + git fetch --all --prune + ``` + + b. List all branches (local and remote) that match the short-name pattern: + ```bash + git branch -a | grep -E "feature/[0-9]+-$" + ``` + + c. Determine the next available number: + - Extract all numbers from existing branches (both local and remote) + - Find the highest number N from branches that exist + - Use N+1 for the new branch number + + d. Run the script `{SCRIPT}` with the calculated number and short-name: + - Pass `--number N+1` and `--short-name "your-short-name"` along with the feature description + - Bash example: `{SCRIPT} --json --number 5 --short-name "user-auth" "Add user authentication"` + - PowerShell example: `{SCRIPT} -Json -Number 5 -ShortName "user-auth" "Add user authentication"` + **IMPORTANT**: - - - Append the short-name argument to the `{SCRIPT}` command with the 2-4 word short name you created in step 1. Keep the feature description as the final argument. - - Bash example: `--short-name "your-generated-short-name" "Feature description here"` - - PowerShell example: `-ShortName "your-generated-short-name" "Feature description here"` + - Only consider branches that still exist (local or remote) + - If no existing branches found with this short-name, start with number 1 + - The JSON output will contain BRANCH_NAME and SPEC_FILE paths + - You must only ever run this script once per feature - For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot") - - You must only ever run this script once - - The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for 3. Load `templates/spec-template.md` to understand required sections.