2025-09-12 10:27:43 -07:00
#!/usr/bin/env bash
2025-09-20 11:38:04 -07:00
2025-09-12 10:27:43 -07:00
set -e
JSON_MODE = false
2025-10-14 10:55:54 -07:00
SHORT_NAME = ""
2025-10-23 12:14:48 +01:00
BRANCH_NUMBER = ""
2025-09-12 10:27:43 -07:00
ARGS =()
2025-10-16 10:02:53 +00:00
i = 1
while [ $i -le $# ] ; do
2025-10-14 10:55:54 -07:00
arg = " ${ !i } "
2025-09-12 10:27:43 -07:00
case " $arg " in
2025-10-14 10:55:54 -07:00
--json)
JSON_MODE = true
;;
--short-name)
2025-10-16 10:02:53 +00:00
if [ $(( i + 1 )) -gt $# ] ; then
2025-10-14 23:39:50 -07:00
echo 'Error: --short-name requires a value' >& 2
exit 1
fi
2025-10-14 10:55:54 -07:00
i = $(( i + 1 ))
2025-10-16 10:02:53 +00:00
next_arg = " ${ !i } "
# Check if the next argument is another option (starts with --)
if [[ " $next_arg " == --* ]] ; then
echo 'Error: --short-name requires a value' >& 2
exit 1
fi
SHORT_NAME = " $next_arg "
2025-10-14 10:55:54 -07:00
;;
2025-10-23 12:14:48 +01:00
--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 "
;;
2025-10-14 10:55:54 -07:00
--help| -h)
2025-10-23 12:14:48 +01:00
echo "Usage: $0 [--json] [--short-name <name>] [--number N] <feature_description>"
2025-10-14 10:55:54 -07:00
echo ""
echo "Options:"
echo " --json Output in JSON format"
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
2025-10-23 12:14:48 +01:00
echo " --number N Specify branch number manually (overrides auto-detection)"
2025-10-14 10:55:54 -07:00
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " $0 'Add user authentication system' --short-name 'user-auth'"
2025-10-23 12:14:48 +01:00
echo " $0 'Implement OAuth2 integration for API' --number 5"
2025-10-14 10:55:54 -07:00
exit 0
;;
*)
ARGS +=( " $arg " )
;;
2025-09-12 10:27:43 -07:00
esac
2025-10-14 10:55:54 -07:00
i = $(( i + 1 ))
2025-09-12 10:27:43 -07:00
done
FEATURE_DESCRIPTION = " ${ ARGS [*] } "
if [ -z " $FEATURE_DESCRIPTION " ] ; then
2025-10-23 12:14:48 +01:00
echo "Usage: $0 [--json] [--short-name <name>] [--number N] <feature_description>" >& 2
2025-09-12 10:27:43 -07:00
exit 1
fi
2025-09-20 20:14:11 -07:00
# Function to find the repository root by searching for existing project markers
find_repo_root() {
local dir = " $1 "
while [ " $dir " != "/" ] ; do
if [ -d " $dir /.git" ] || [ -d " $dir /.specify" ] ; then
echo " $dir "
return 0
fi
dir = " $( dirname " $dir " ) "
done
return 1
}
2025-10-23 12:14:48 +01:00
# 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
2025-10-23 12:25:31 +01:00
# Find all branches matching the pattern using git ls-remote (more reliable)
local remote_branches = $( git ls-remote --heads origin 2>/dev/null | grep -E "refs/heads/[0-9]+- ${ short_name } $" | sed 's/.*\/\([0-9]*\)-.*/\1/' | sort -n)
2025-10-23 12:14:48 +01:00
2025-10-23 12:25:31 +01:00
# Also check local branches
local local_branches = $( git branch 2>/dev/null | grep -E "^[* ]*[0-9]+- ${ short_name } $" | sed 's/^[* ]*//' | sed 's/-.*//' | sort -n)
# Check specs directory as well
local spec_dirs = ""
if [ -d " $SPECS_DIR " ] ; then
spec_dirs = $( find " $SPECS_DIR " -maxdepth 1 -type d -name "[0-9]*- ${ short_name } " 2>/dev/null | xargs -n1 basename 2>/dev/null | sed 's/-.*//' | sort -n)
fi
# Combine all sources and get the highest number
2025-10-23 12:14:48 +01:00
local max_num = 0
2025-10-23 12:25:31 +01:00
for num in $remote_branches $local_branches $spec_dirs ; do
2025-10-23 12:14:48 +01:00
if [ " $num " -gt " $max_num " ] ; then
max_num = $num
fi
done
# Return next number
echo $(( max_num + 1 ))
}
2025-09-20 22:44:44 +09:00
# Resolve repository root. Prefer git information when available, but fall back
2025-09-20 20:14:11 -07:00
# to searching for repository markers so the workflow still functions in repositories that
2025-09-20 22:44:44 +09:00
# were initialised with --no-git.
SCRIPT_DIR = " $( cd " $( dirname " ${ BASH_SOURCE [0] } " ) " && pwd ) "
2025-09-20 20:14:11 -07:00
2025-09-20 22:44:44 +09:00
if git rev-parse --show-toplevel >/dev/null 2>& 1; then
REPO_ROOT = $( git rev-parse --show-toplevel)
HAS_GIT = true
else
2025-09-20 20:14:11 -07:00
REPO_ROOT = " $( find_repo_root " $SCRIPT_DIR " ) "
if [ -z " $REPO_ROOT " ] ; then
echo "Error: Could not determine repository root. Please run this script from within the repository." >& 2
exit 1
fi
2025-09-20 22:44:44 +09:00
HAS_GIT = false
fi
cd " $REPO_ROOT "
2025-09-12 10:27:43 -07:00
SPECS_DIR = " $REPO_ROOT /specs"
mkdir -p " $SPECS_DIR "
2025-10-14 10:55:54 -07:00
# Function to generate branch name with stop word filtering and length filtering
generate_branch_name() {
local description = " $1 "
# Common stop words to filter out
local stop_words = "^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set) $"
# Convert to lowercase and split into words
local clean_name = $( echo " $description " | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g' )
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
local meaningful_words =()
for word in $clean_name ; do
# Skip empty words
[ -z " $word " ] && continue
# Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)
if ! echo " $word " | grep -qiE " $stop_words " ; then
if [ ${# word } -ge 3 ] ; then
meaningful_words +=( " $word " )
elif echo " $description " | grep -q "\b ${ word ^^ } \b" ; then
# Keep short words if they appear as uppercase in original (likely acronyms)
meaningful_words +=( " $word " )
fi
fi
done
# If we have meaningful words, use first 3-4 of them
if [ ${# meaningful_words [@] } -gt 0 ] ; then
local max_words = 3
if [ ${# meaningful_words [@] } -eq 4 ] ; then max_words = 4; fi
local result = ""
local count = 0
for word in " ${ meaningful_words [@] } " ; do
if [ $count -ge $max_words ] ; then break; fi
if [ -n " $result " ] ; then result = " $result -" ; fi
result = " $result$word "
count = $(( count + 1 ))
done
echo " $result "
else
# Fallback to original logic if no meaningful words found
echo " $description " | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
fi
}
# Generate branch name
if [ -n " $SHORT_NAME " ] ; then
# Use provided short name, just clean it up
BRANCH_SUFFIX = $( echo " $SHORT_NAME " | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' )
else
# Generate from description with smart filtering
BRANCH_SUFFIX = $( generate_branch_name " $FEATURE_DESCRIPTION " )
fi
2025-10-23 12:14:48 +01:00
# 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 " )
2025-10-14 10:55:54 -07:00
BRANCH_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: feature number (3) + hyphen (1) = 4 chars
MAX_SUFFIX_LENGTH = $(( MAX_BRANCH_LENGTH - 4 ))
# 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 } "
>& 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
2025-09-12 10:27:43 -07:00
2025-09-20 22:44:44 +09:00
if [ " $HAS_GIT " = true ] ; then
git checkout -b " $BRANCH_NAME "
else
>& 2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME "
fi
2025-09-12 10:27:43 -07:00
FEATURE_DIR = " $SPECS_DIR / $BRANCH_NAME "
mkdir -p " $FEATURE_DIR "
2025-09-24 21:30:22 +08:00
TEMPLATE = " $REPO_ROOT /.specify/templates/spec-template.md"
2025-09-12 10:27:43 -07:00
SPEC_FILE = " $FEATURE_DIR /spec.md"
if [ -f " $TEMPLATE " ] ; then cp " $TEMPLATE " " $SPEC_FILE " ; else touch " $SPEC_FILE " ; fi
2025-09-20 15:04:25 -07:00
# Set the SPECIFY_FEATURE environment variable for the current session
export SPECIFY_FEATURE = " $BRANCH_NAME "
2025-09-12 10:27:43 -07:00
if $JSON_MODE ; then
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' " $BRANCH_NAME " " $SPEC_FILE " " $FEATURE_NUM "
else
echo "BRANCH_NAME: $BRANCH_NAME "
echo "SPEC_FILE: $SPEC_FILE "
echo "FEATURE_NUM: $FEATURE_NUM "
2025-09-20 15:04:25 -07:00
echo "SPECIFY_FEATURE environment variable set to: $BRANCH_NAME "
2025-10-17 09:03:47 +08:00
fi