mirror of
https://github.com/github/spec-kit.git
synced 2026-03-16 18:33:07 +00:00
- Replace eval of unquoted get_feature_paths output with safe pattern: capture into variable, check return code, then eval quoted result - Use printf '%q' in get_feature_paths to safely emit shell assignments, preventing injection via paths containing quotes or metacharacters - Add json_escape() helper for printf JSON fallback paths, handling backslash, double-quote, and control characters when jq is unavailable - Use jq -cn for safe JSON construction with proper escaping when available, with printf + json_escape() fallback - Replace declare -A (bash 4+) with indexed array for bash 3.2 compatibility (macOS default) - Use inline command -v jq check in create-new-feature.sh since it does not source common.sh - Guard trap cleanup against re-entrant invocation by disarming traps at entry - Use printf '%q' for shell-escaped branch names in user-facing output - Return failure instead of silently returning wrong path on ambiguous spec directory matches - Deduplicate agent file updates via realpath to prevent multiple writes to the same file (e.g. AGENTS.md aliased by multiple variables)
178 lines
5.6 KiB
Bash
178 lines
5.6 KiB
Bash
#!/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 control characters (newline, tab, carriage return).
|
|
json_escape() {
|
|
local s="$1"
|
|
s="${s//\\/\\\\}"
|
|
s="${s//\"/\\\"}"
|
|
s="${s//$'\n'/\\n}"
|
|
s="${s//$'\t'/\\t}"
|
|
s="${s//$'\r'/\\r}"
|
|
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"; }
|
|
|