#!/usr/bin/env bash # Common functions and variables for all scripts # Get repository root, with fallback for non-git repositories get_repo_root() { if git rev-parse --show-toplevel >/dev/null 2>&1; then git rev-parse --show-toplevel else # Fall back to script location for non-git repos local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" (cd "$script_dir/../../.." && pwd) fi } # Get current branch, with fallback for non-git repositories get_current_branch() { # First check if SPECIFY_FEATURE environment variable is set if [[ -n "${SPECIFY_FEATURE:-}" ]]; then echo "$SPECIFY_FEATURE" return fi # Then check git if available if git rev-parse --abbrev-ref HEAD >/dev/null 2>&1; then git rev-parse --abbrev-ref HEAD return fi # For non-git repos, try to find the latest feature directory local repo_root=$(get_repo_root) local specs_dir="$repo_root/specs" if [[ -d "$specs_dir" ]]; then local latest_feature="" local highest=0 for dir in "$specs_dir"/*; do if [[ -d "$dir" ]]; then local dirname=$(basename "$dir") if [[ "$dirname" =~ ^([0-9]{3})- ]]; then local number=${BASH_REMATCH[1]} number=$((10#$number)) if [[ "$number" -gt "$highest" ]]; then highest=$number latest_feature=$dirname fi fi fi done if [[ -n "$latest_feature" ]]; then echo "$latest_feature" return fi fi echo "main" # Final fallback } # Check if we have git available has_git() { git rev-parse --show-toplevel >/dev/null 2>&1 } check_feature_branch() { local branch="$1" local has_git_repo="$2" # For non-git repos, we can't enforce branch naming but still provide output if [[ "$has_git_repo" != "true" ]]; then echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2 return 0 fi if [[ ! "$branch" =~ ^[0-9]{3}- ]]; then echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 echo "Feature branches should be named like: 001-feature-name" >&2 return 1 fi return 0 } get_feature_dir() { echo "$1/specs/$2"; } # Find feature directory by numeric prefix instead of exact branch match # This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature) find_feature_dir_by_prefix() { local repo_root="$1" local branch_name="$2" local specs_dir="$repo_root/specs" # Extract numeric prefix from branch (e.g., "004" from "004-whatever") if [[ ! "$branch_name" =~ ^([0-9]{3})- ]]; then # If branch doesn't have numeric prefix, fall back to exact match echo "$specs_dir/$branch_name" return fi local prefix="${BASH_REMATCH[1]}" # Search for directories in specs/ that start with this prefix local matches=() if [[ -d "$specs_dir" ]]; then for dir in "$specs_dir"/"$prefix"-*; do if [[ -d "$dir" ]]; then matches+=("$(basename "$dir")") fi done fi # Handle results if [[ ${#matches[@]} -eq 0 ]]; then # No match found - return the branch name path (will fail later with clear error) echo "$specs_dir/$branch_name" elif [[ ${#matches[@]} -eq 1 ]]; then # Exactly one match - perfect! echo "$specs_dir/${matches[0]}" else # Multiple matches - this shouldn't happen with proper naming convention echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2 echo "Please ensure only one spec directory exists per numeric prefix." >&2 return 1 fi } get_feature_paths() { local repo_root=$(get_repo_root) local current_branch=$(get_current_branch) local has_git_repo="false" if has_git; then has_git_repo="true" fi # Use prefix-based lookup to support multiple branches per spec local feature_dir if ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then echo "ERROR: Failed to resolve feature directory" >&2 return 1 fi # Use printf '%q' to safely quote values, preventing shell injection # via crafted branch names or paths containing special characters printf 'REPO_ROOT=%q\n' "$repo_root" printf 'CURRENT_BRANCH=%q\n' "$current_branch" printf 'HAS_GIT=%q\n' "$has_git_repo" printf 'FEATURE_DIR=%q\n' "$feature_dir" printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md" printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md" printf 'TASKS=%q\n' "$feature_dir/tasks.md" printf 'RESEARCH=%q\n' "$feature_dir/research.md" printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md" printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md" printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts" } # Check if jq is available for safe JSON construction has_jq() { command -v jq >/dev/null 2>&1 } # Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). # Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259). json_escape() { local s="$1" s="${s//\\/\\\\}" s="${s//\"/\\\"}" s="${s//$'\n'/\\n}" s="${s//$'\t'/\\t}" s="${s//$'\r'/\\r}" s="${s//$'\b'/\\b}" s="${s//$'\f'/\\f}" # Strip remaining control characters (U+0000–U+001F) not individually escaped above s=$(printf '%s' "$s" | tr -d '\000-\007\013\016-\037') printf '%s' "$s" } check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } # Resolve a template name to a file path using the priority stack: # 1. .specify/templates/overrides/ # 2. .specify/presets//templates/ (sorted by priority from .registry) # 3. .specify/extensions//templates/ # 4. .specify/templates/ (core) resolve_template() { local template_name="$1" local repo_root="$2" local base="$repo_root/.specify/templates" # Priority 1: Project overrides local override="$base/overrides/${template_name}.md" [ -f "$override" ] && echo "$override" && return 0 # Priority 2: Installed presets (sorted by priority from .registry) local presets_dir="$repo_root/.specify/presets" if [ -d "$presets_dir" ]; then local registry_file="$presets_dir/.registry" if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then # Read preset IDs sorted by priority (lower number = higher precedence). # The python3 call is wrapped in an if-condition so that set -e does not # abort the function when python3 exits non-zero (e.g. invalid JSON). local sorted_presets="" if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " import json, sys, os try: with open(os.environ['SPECKIT_REGISTRY']) as f: data = json.load(f) presets = data.get('presets', {}) for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)): print(pid) except Exception: sys.exit(1) " 2>/dev/null); then if [ -n "$sorted_presets" ]; then # python3 succeeded and returned preset IDs — search in priority order while IFS= read -r preset_id; do local candidate="$presets_dir/$preset_id/templates/${template_name}.md" [ -f "$candidate" ] && echo "$candidate" && return 0 done <<< "$sorted_presets" fi # python3 succeeded but registry has no presets — nothing to search else # python3 failed (missing, or registry parse error) — fall back to unordered directory scan for preset in "$presets_dir"/*/; do [ -d "$preset" ] || continue local candidate="$preset/templates/${template_name}.md" [ -f "$candidate" ] && echo "$candidate" && return 0 done fi else # Fallback: alphabetical directory order (no python3 available) for preset in "$presets_dir"/*/; do [ -d "$preset" ] || continue local candidate="$preset/templates/${template_name}.md" [ -f "$candidate" ] && echo "$candidate" && return 0 done fi fi # Priority 3: Extension-provided templates local ext_dir="$repo_root/.specify/extensions" if [ -d "$ext_dir" ]; then for ext in "$ext_dir"/*/; do [ -d "$ext" ] || continue # Skip hidden directories (e.g. .backup, .cache) case "$(basename "$ext")" in .*) continue;; esac local candidate="$ext/templates/${template_name}.md" [ -f "$candidate" ] && echo "$candidate" && return 0 done fi # Priority 4: Core templates local core="$base/${template_name}.md" [ -f "$core" ] && echo "$core" && return 0 # Template not found in any location. # Return 1 so callers can distinguish "not found" from "found". # Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true return 1 }