create-new-feature.sh 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. #!/usr/bin/env bash
  2. # Git extension: create-new-feature.sh
  3. # Adapted from core scripts/bash/create-new-feature.sh for extension layout.
  4. # Sources common.sh from the project's installed scripts, falling back to
  5. # git-common.sh for minimal git helpers.
  6. set -e
  7. JSON_MODE=false
  8. DRY_RUN=false
  9. ALLOW_EXISTING=false
  10. SHORT_NAME=""
  11. BRANCH_NUMBER=""
  12. USE_TIMESTAMP=false
  13. ARGS=()
  14. i=1
  15. while [ $i -le $# ]; do
  16. arg="${!i}"
  17. case "$arg" in
  18. --json)
  19. JSON_MODE=true
  20. ;;
  21. --dry-run)
  22. DRY_RUN=true
  23. ;;
  24. --allow-existing-branch)
  25. ALLOW_EXISTING=true
  26. ;;
  27. --short-name)
  28. if [ $((i + 1)) -gt $# ]; then
  29. echo 'Error: --short-name requires a value' >&2
  30. exit 1
  31. fi
  32. i=$((i + 1))
  33. next_arg="${!i}"
  34. if [[ "$next_arg" == --* ]]; then
  35. echo 'Error: --short-name requires a value' >&2
  36. exit 1
  37. fi
  38. SHORT_NAME="$next_arg"
  39. ;;
  40. --number)
  41. if [ $((i + 1)) -gt $# ]; then
  42. echo 'Error: --number requires a value' >&2
  43. exit 1
  44. fi
  45. i=$((i + 1))
  46. next_arg="${!i}"
  47. if [[ "$next_arg" == --* ]]; then
  48. echo 'Error: --number requires a value' >&2
  49. exit 1
  50. fi
  51. BRANCH_NUMBER="$next_arg"
  52. if [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
  53. echo 'Error: --number must be a non-negative integer' >&2
  54. exit 1
  55. fi
  56. ;;
  57. --timestamp)
  58. USE_TIMESTAMP=true
  59. ;;
  60. --help|-h)
  61. echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
  62. echo ""
  63. echo "Options:"
  64. echo " --json Output in JSON format"
  65. echo " --dry-run Compute branch name without creating the branch"
  66. echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
  67. echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
  68. echo " --number N Specify branch number manually (overrides auto-detection)"
  69. echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
  70. echo " --help, -h Show this help message"
  71. echo ""
  72. echo "Environment variables:"
  73. echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
  74. echo ""
  75. echo "Examples:"
  76. echo " $0 'Add user authentication system' --short-name 'user-auth'"
  77. echo " $0 'Implement OAuth2 integration for API' --number 5"
  78. echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
  79. echo " GIT_BRANCH_NAME=my-branch $0 'feature description'"
  80. exit 0
  81. ;;
  82. *)
  83. ARGS+=("$arg")
  84. ;;
  85. esac
  86. i=$((i + 1))
  87. done
  88. FEATURE_DESCRIPTION="${ARGS[*]}"
  89. if [ -z "$FEATURE_DESCRIPTION" ]; then
  90. echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
  91. exit 1
  92. fi
  93. # Trim whitespace and validate description is not empty
  94. FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
  95. if [ -z "$FEATURE_DESCRIPTION" ]; then
  96. echo "Error: Feature description cannot be empty or contain only whitespace" >&2
  97. exit 1
  98. fi
  99. # Function to get highest number from specs directory
  100. get_highest_from_specs() {
  101. local specs_dir="$1"
  102. local highest=0
  103. if [ -d "$specs_dir" ]; then
  104. for dir in "$specs_dir"/*; do
  105. [ -d "$dir" ] || continue
  106. dirname=$(basename "$dir")
  107. # Match sequential prefixes (>=3 digits), but skip timestamp dirs.
  108. if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
  109. number=$(echo "$dirname" | grep -Eo '^[0-9]+')
  110. number=$((10#$number))
  111. if [ "$number" -gt "$highest" ]; then
  112. highest=$number
  113. fi
  114. fi
  115. done
  116. fi
  117. echo "$highest"
  118. }
  119. # Function to get highest number from git branches
  120. get_highest_from_branches() {
  121. git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
  122. }
  123. # Extract the highest sequential feature number from a list of ref names (one per line).
  124. _extract_highest_number() {
  125. local highest=0
  126. while IFS= read -r name; do
  127. [ -z "$name" ] && continue
  128. if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
  129. number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
  130. number=$((10#$number))
  131. if [ "$number" -gt "$highest" ]; then
  132. highest=$number
  133. fi
  134. fi
  135. done
  136. echo "$highest"
  137. }
  138. # Function to get highest number from remote branches without fetching (side-effect-free)
  139. get_highest_from_remote_refs() {
  140. local highest=0
  141. for remote in $(git remote 2>/dev/null); do
  142. local remote_highest
  143. remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
  144. if [ "$remote_highest" -gt "$highest" ]; then
  145. highest=$remote_highest
  146. fi
  147. done
  148. echo "$highest"
  149. }
  150. # Function to check existing branches and return next available number.
  151. check_existing_branches() {
  152. local specs_dir="$1"
  153. local skip_fetch="${2:-false}"
  154. if [ "$skip_fetch" = true ]; then
  155. local highest_remote=$(get_highest_from_remote_refs)
  156. local highest_branch=$(get_highest_from_branches)
  157. if [ "$highest_remote" -gt "$highest_branch" ]; then
  158. highest_branch=$highest_remote
  159. fi
  160. else
  161. git fetch --all --prune >/dev/null 2>&1 || true
  162. local highest_branch=$(get_highest_from_branches)
  163. fi
  164. local highest_spec=$(get_highest_from_specs "$specs_dir")
  165. local max_num=$highest_branch
  166. if [ "$highest_spec" -gt "$max_num" ]; then
  167. max_num=$highest_spec
  168. fi
  169. echo $((max_num + 1))
  170. }
  171. # Function to clean and format a branch name
  172. clean_branch_name() {
  173. local name="$1"
  174. echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
  175. }
  176. # ---------------------------------------------------------------------------
  177. # Source common.sh for resolve_template, json_escape, get_repo_root, has_git.
  178. #
  179. # Search locations in priority order:
  180. # 1. .specify/scripts/bash/common.sh under the project root (installed project)
  181. # 2. scripts/bash/common.sh under the project root (source checkout fallback)
  182. # 3. git-common.sh next to this script (minimal fallback — lacks resolve_template)
  183. # ---------------------------------------------------------------------------
  184. SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  185. # Find project root by walking up from the script location
  186. _find_project_root() {
  187. local dir="$1"
  188. while [ "$dir" != "/" ]; do
  189. if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
  190. echo "$dir"
  191. return 0
  192. fi
  193. dir="$(dirname "$dir")"
  194. done
  195. return 1
  196. }
  197. _common_loaded=false
  198. _PROJECT_ROOT=$(_find_project_root "$SCRIPT_DIR") || true
  199. if [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" ]; then
  200. source "$_PROJECT_ROOT/.specify/scripts/bash/common.sh"
  201. _common_loaded=true
  202. elif [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/scripts/bash/common.sh" ]; then
  203. source "$_PROJECT_ROOT/scripts/bash/common.sh"
  204. _common_loaded=true
  205. elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then
  206. source "$SCRIPT_DIR/git-common.sh"
  207. _common_loaded=true
  208. fi
  209. if [ "$_common_loaded" != "true" ]; then
  210. echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2
  211. exit 1
  212. fi
  213. # Resolve repository root
  214. if type get_repo_root >/dev/null 2>&1; then
  215. REPO_ROOT=$(get_repo_root)
  216. elif git rev-parse --show-toplevel >/dev/null 2>&1; then
  217. REPO_ROOT=$(git rev-parse --show-toplevel)
  218. elif [ -n "$_PROJECT_ROOT" ]; then
  219. REPO_ROOT="$_PROJECT_ROOT"
  220. else
  221. echo "Error: Could not determine repository root." >&2
  222. exit 1
  223. fi
  224. # Check if git is available at this repo root
  225. if type has_git >/dev/null 2>&1; then
  226. if has_git "$REPO_ROOT"; then
  227. HAS_GIT=true
  228. else
  229. HAS_GIT=false
  230. fi
  231. elif git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
  232. HAS_GIT=true
  233. else
  234. HAS_GIT=false
  235. fi
  236. cd "$REPO_ROOT"
  237. SPECS_DIR="$REPO_ROOT/specs"
  238. # Function to generate branch name with stop word filtering
  239. generate_branch_name() {
  240. local description="$1"
  241. 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)$"
  242. local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
  243. local meaningful_words=()
  244. for word in $clean_name; do
  245. [ -z "$word" ] && continue
  246. if ! echo "$word" | grep -qiE "$stop_words"; then
  247. if [ ${#word} -ge 3 ]; then
  248. meaningful_words+=("$word")
  249. elif echo "$description" | grep -qw -- "${word^^}"; then
  250. meaningful_words+=("$word")
  251. fi
  252. fi
  253. done
  254. if [ ${#meaningful_words[@]} -gt 0 ]; then
  255. local max_words=3
  256. if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
  257. local result=""
  258. local count=0
  259. for word in "${meaningful_words[@]}"; do
  260. if [ $count -ge $max_words ]; then break; fi
  261. if [ -n "$result" ]; then result="$result-"; fi
  262. result="$result$word"
  263. count=$((count + 1))
  264. done
  265. echo "$result"
  266. else
  267. local cleaned=$(clean_branch_name "$description")
  268. echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
  269. fi
  270. }
  271. # Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
  272. if [ -n "${GIT_BRANCH_NAME:-}" ]; then
  273. BRANCH_NAME="$GIT_BRANCH_NAME"
  274. # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
  275. # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern
  276. if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
  277. FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}')
  278. BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
  279. elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then
  280. FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+')
  281. BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
  282. else
  283. FEATURE_NUM="$BRANCH_NAME"
  284. BRANCH_SUFFIX="$BRANCH_NAME"
  285. fi
  286. else
  287. # Generate branch name
  288. if [ -n "$SHORT_NAME" ]; then
  289. BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
  290. else
  291. BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
  292. fi
  293. # Warn if --number and --timestamp are both specified
  294. if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
  295. >&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
  296. BRANCH_NUMBER=""
  297. fi
  298. # Determine branch prefix
  299. if [ "$USE_TIMESTAMP" = true ]; then
  300. FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
  301. BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
  302. else
  303. if [ -z "$BRANCH_NUMBER" ]; then
  304. if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
  305. BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
  306. elif [ "$DRY_RUN" = true ]; then
  307. HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
  308. BRANCH_NUMBER=$((HIGHEST + 1))
  309. elif [ "$HAS_GIT" = true ]; then
  310. BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
  311. else
  312. HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
  313. BRANCH_NUMBER=$((HIGHEST + 1))
  314. fi
  315. fi
  316. FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
  317. BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
  318. fi
  319. fi
  320. # GitHub enforces a 244-byte limit on branch names
  321. MAX_BRANCH_LENGTH=244
  322. _byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; }
  323. BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
  324. if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
  325. >&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
  326. exit 1
  327. elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
  328. PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
  329. MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
  330. TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
  331. TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
  332. ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
  333. BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
  334. >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
  335. >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
  336. >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
  337. fi
  338. if [ "$DRY_RUN" != true ]; then
  339. if [ "$HAS_GIT" = true ]; then
  340. branch_create_error=""
  341. if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
  342. current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
  343. if git branch --list "$BRANCH_NAME" | grep -q .; then
  344. if [ "$ALLOW_EXISTING" = true ]; then
  345. if [ "$current_branch" = "$BRANCH_NAME" ]; then
  346. :
  347. elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
  348. >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
  349. if [ -n "$switch_branch_error" ]; then
  350. >&2 printf '%s\n' "$switch_branch_error"
  351. fi
  352. exit 1
  353. fi
  354. elif [ "$USE_TIMESTAMP" = true ]; then
  355. >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
  356. exit 1
  357. else
  358. >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
  359. exit 1
  360. fi
  361. else
  362. >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
  363. if [ -n "$branch_create_error" ]; then
  364. >&2 printf '%s\n' "$branch_create_error"
  365. else
  366. >&2 echo "Please check your git configuration and try again."
  367. fi
  368. exit 1
  369. fi
  370. fi
  371. else
  372. >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
  373. fi
  374. printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
  375. fi
  376. if $JSON_MODE; then
  377. if command -v jq >/dev/null 2>&1; then
  378. if [ "$DRY_RUN" = true ]; then
  379. jq -cn \
  380. --arg branch_name "$BRANCH_NAME" \
  381. --arg feature_num "$FEATURE_NUM" \
  382. '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}'
  383. else
  384. jq -cn \
  385. --arg branch_name "$BRANCH_NAME" \
  386. --arg feature_num "$FEATURE_NUM" \
  387. '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}'
  388. fi
  389. else
  390. if type json_escape >/dev/null 2>&1; then
  391. _je_branch=$(json_escape "$BRANCH_NAME")
  392. _je_num=$(json_escape "$FEATURE_NUM")
  393. else
  394. _je_branch="$BRANCH_NAME"
  395. _je_num="$FEATURE_NUM"
  396. fi
  397. if [ "$DRY_RUN" = true ]; then
  398. printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num"
  399. else
  400. printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num"
  401. fi
  402. fi
  403. else
  404. echo "BRANCH_NAME: $BRANCH_NAME"
  405. echo "FEATURE_NUM: $FEATURE_NUM"
  406. if [ "$DRY_RUN" != true ]; then
  407. printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
  408. fi
  409. fi