mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
feat: make git extension opt-in and remove --no-git at v0.10.0 (#2873)
* feat(init)!: make git extension opt-in and remove --no-git at v0.10.0 - Remove --no-git parameter from specify init command - Remove git extension auto-installation from init flow - Git repository initialization (git init) still runs when git is available - Remove --no-git from all test invocations across the test suite - Update docs to reflect opt-in git extension behavior - Replace TestGitExtensionAutoInstall with TestGitExtensionOptIn tests BREAKING CHANGE: specify init no longer auto-installs the git extension. Use `specify extension add git` to install it explicitly. The --no-git flag has been removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(scripts): remove git operations from core scripts Git functionality is now entirely managed by the git extension. Core scripts only handle directory-based feature creation and numbering. - Remove has_git(), check_feature_branch(), git branch creation from core - Simplify number detection to use only spec directory scanning - Remove HAS_GIT output from get_feature_paths() - Remove git remote fetching and branch querying - Keep BRANCH_NAME output key for backward compatibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: remove all git operations from core - Remove is_git_repo() and init_git_repo() dead code from _utils.py - Remove --branch-numbering from init command - Remove git from 'specify check' (now extension-only) - Update docs: git is optional prerequisite, check command description - Fix tests to reflect no-git-in-core reality (fallback to main) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(scripts): remove directory scanning and branch fallback from core Core scripts now resolve feature context exclusively from: 1. SPECIFY_FEATURE env var (set by git extension) 2. .specify/feature.json (persisted by specify command) Removed find_feature_dir_by_prefix() and directory scanning heuristics — these are the git extension's responsibility. Scripts error clearly when no feature context is available. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: introduce feature_numbering, deprecate branch_numbering in init-options - specify command template now reads feature_numbering (preferred) with fallback to branch_numbering (deprecated) from init-options.json - Git extension reads git-config.yml > feature_numbering > branch_numbering - init now writes feature_numbering: sequential to init-options.json - Deprecation warning emitted when branch_numbering is used as fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove trailing whitespace in common.ps1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(scripts): persist SPECIFY_FEATURE_DIRECTORY env var to feature.json When SPECIFY_FEATURE_DIRECTORY is set, get_feature_paths() now writes the value to .specify/feature.json so future sessions without the env var can still resolve the feature directory. The write is idempotent — it skips when the file already contains the same value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address review feedback — error messages and docs - Update error messages in common.sh and common.ps1 to reference SPECIFY_FEATURE_DIRECTORY instead of SPECIFY_FEATURE (which no longer resolves feature directories) - Fix get_current_branch comment (returns empty string, not error) - Update upgrade.md to reference SPECIFY_FEATURE_DIRECTORY with correct example paths - Update local-development.md troubleshooting: replace stale 'Git step skipped' row with actionable git extension guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): harden feature.json persistence - Use json_escape in printf fallback when jq is unavailable (common.sh) - Replace utf8NoBOM encoding with UTF8Encoding($false) for PowerShell 5.1 compatibility (common.ps1) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(scripts): remove dead feature_json_matches_feature_dir functions These guards are no longer needed since the branch-name validation they protected against has been removed from check-prerequisites. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(git-ext): rename create-new-feature to create-new-feature-branch The git extension's script only creates the git branch — rename it to reflect that responsibility. The core create-new-feature.sh/.ps1 handles feature directory creation and feature.json persistence. Also includes fixes from review feedback: - common.sh: _persist_feature_json uses json_escape fallback - common.ps1: Save-FeatureJson uses UTF8Encoding for PS 5.1 compat - common.ps1: case-sensitive path stripping on non-Windows - create-new-feature.sh/ps1: output both SPECIFY_FEATURE and SPECIFY_FEATURE_DIRECTORY - setup-tasks.sh: fix stale 'Validate branch' comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): update references to renamed git extension scripts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): remove duplicate EXT_CREATE_FEATURE assignments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -111,9 +111,6 @@ if $PATHS_ONLY; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Validate branch name
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
|
||||
# Validate required directories and files
|
||||
if [[ ! -d "$FEATURE_DIR" ]]; then
|
||||
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
|
||||
|
||||
@@ -24,8 +24,8 @@ find_specify_root() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Get repository root, prioritizing .specify directory over git
|
||||
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
|
||||
# Get repository root, prioritizing .specify directory
|
||||
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
|
||||
get_repo_root() {
|
||||
# First, look for .specify directory (spec-kit's own marker)
|
||||
local specify_root
|
||||
@@ -34,123 +34,24 @@ get_repo_root() {
|
||||
return
|
||||
fi
|
||||
|
||||
# Fallback to git if no .specify found
|
||||
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
git rev-parse --show-toplevel
|
||||
return
|
||||
fi
|
||||
|
||||
# Final fallback to script location for non-git repos
|
||||
# Final fallback to script location
|
||||
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
(cd "$script_dir/../../.." && pwd)
|
||||
}
|
||||
|
||||
# Get current branch, with fallback for non-git repositories
|
||||
# Get current feature name from explicit state only.
|
||||
# Returns the feature identifier or empty string if none is set.
|
||||
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
|
||||
# the git extension) or implicitly via .specify/feature.json.
|
||||
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 at the spec-kit root (not parent)
|
||||
local repo_root=$(get_repo_root)
|
||||
if has_git; then
|
||||
git -C "$repo_root" rev-parse --abbrev-ref HEAD
|
||||
return
|
||||
fi
|
||||
|
||||
# For non-git repos, try to find the latest feature directory
|
||||
local specs_dir="$repo_root/specs"
|
||||
|
||||
if [[ -d "$specs_dir" ]]; then
|
||||
local latest_feature=""
|
||||
local highest=0
|
||||
local latest_timestamp=""
|
||||
|
||||
for dir in "$specs_dir"/*; do
|
||||
if [[ -d "$dir" ]]; then
|
||||
local dirname=$(basename "$dir")
|
||||
if [[ "$dirname" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
|
||||
# Timestamp-based branch: compare lexicographically
|
||||
local ts="${BASH_REMATCH[1]}"
|
||||
if [[ "$ts" > "$latest_timestamp" ]]; then
|
||||
latest_timestamp="$ts"
|
||||
latest_feature=$dirname
|
||||
fi
|
||||
elif [[ "$dirname" =~ ^([0-9]{3,})- ]]; then
|
||||
local number=${BASH_REMATCH[1]}
|
||||
number=$((10#$number))
|
||||
if [[ "$number" -gt "$highest" ]]; then
|
||||
highest=$number
|
||||
# Only update if no timestamp branch found yet
|
||||
if [[ -z "$latest_timestamp" ]]; then
|
||||
latest_feature=$dirname
|
||||
fi
|
||||
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 at the spec-kit root level
|
||||
# Returns true only if git is installed and the repo root is inside a git work tree
|
||||
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
|
||||
has_git() {
|
||||
# First check if git command is available (before calling get_repo_root which may use git)
|
||||
command -v git >/dev/null 2>&1 || return 1
|
||||
local repo_root=$(get_repo_root)
|
||||
# Check if .git exists (directory or file for worktrees/submodules)
|
||||
[ -e "$repo_root/.git" ] || return 1
|
||||
# Verify it's actually a valid git work tree
|
||||
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
|
||||
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
|
||||
spec_kit_effective_branch_name() {
|
||||
local raw="$1"
|
||||
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
|
||||
printf '%s\n' "${BASH_REMATCH[2]}"
|
||||
else
|
||||
printf '%s\n' "$raw"
|
||||
fi
|
||||
}
|
||||
|
||||
check_feature_branch() {
|
||||
local raw="$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
|
||||
|
||||
local branch
|
||||
branch=$(spec_kit_effective_branch_name "$raw")
|
||||
|
||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||
local is_sequential=false
|
||||
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
|
||||
is_sequential=true
|
||||
fi
|
||||
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
|
||||
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
# No explicit feature set — caller must handle this via feature.json
|
||||
# in get_feature_paths(). Return empty to signal "unknown".
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Safely read .specify/feature.json's "feature_directory" value.
|
||||
@@ -185,105 +86,66 @@ read_feature_json_feature_directory() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Returns 0 when .specify/feature.json lists feature_directory that exists as a directory
|
||||
# and matches the resolved active FEATURE_DIR (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks).
|
||||
# Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`.
|
||||
feature_json_matches_feature_dir() {
|
||||
# Persist a feature_directory value to .specify/feature.json.
|
||||
# Writes only when the file is missing or the value differs from what's stored.
|
||||
# Accepts the raw (possibly relative) path — callers should pass the original
|
||||
# user-supplied value, not the normalized absolute path.
|
||||
_persist_feature_json() {
|
||||
local repo_root="$1"
|
||||
local active_feature_dir="$2"
|
||||
local feature_dir_value="$2"
|
||||
local fj="$repo_root/.specify/feature.json"
|
||||
|
||||
local _fd
|
||||
_fd=$(read_feature_json_feature_directory "$repo_root")
|
||||
|
||||
[[ -n "$_fd" ]] || return 1
|
||||
[[ "$_fd" != /* ]] && _fd="$repo_root/$_fd"
|
||||
[[ -d "$_fd" ]] || return 1
|
||||
|
||||
local norm_json norm_active
|
||||
norm_json="$(cd -- "$_fd" 2>/dev/null && pwd -P)" || return 1
|
||||
norm_active="$(cd -- "$active_feature_dir" 2>/dev/null && pwd -P)" || return 1
|
||||
|
||||
[[ "$norm_json" == "$norm_active" ]]
|
||||
}
|
||||
|
||||
# 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
|
||||
branch_name=$(spec_kit_effective_branch_name "$2")
|
||||
local specs_dir="$repo_root/specs"
|
||||
|
||||
# Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches)
|
||||
local prefix=""
|
||||
if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
|
||||
prefix="${BASH_REMATCH[1]}"
|
||||
elif [[ "$branch_name" =~ ^([0-9]{3,})- ]]; then
|
||||
prefix="${BASH_REMATCH[1]}"
|
||||
else
|
||||
# If branch doesn't have a recognized prefix, fall back to exact match
|
||||
echo "$specs_dir/$branch_name"
|
||||
return
|
||||
# Strip repo_root prefix if the value is absolute and under repo_root
|
||||
if [[ "$feature_dir_value" == "$repo_root/"* ]]; then
|
||||
feature_dir_value="${feature_dir_value#"$repo_root/"}"
|
||||
fi
|
||||
|
||||
# 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
|
||||
# Read current value (if any) and skip write when unchanged
|
||||
local current_val
|
||||
current_val=$(read_feature_json_feature_directory "$repo_root")
|
||||
if [[ "$current_val" == "$feature_dir_value" ]]; then
|
||||
return 0
|
||||
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]}"
|
||||
# Ensure .specify/ directory exists
|
||||
mkdir -p "$repo_root/.specify"
|
||||
|
||||
# Write feature.json — prefer jq for safe JSON, fall back to printf
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq -cn --arg fd "$feature_dir_value" '{feature_directory:$fd}' > "$fj"
|
||||
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 prefix." >&2
|
||||
return 1
|
||||
printf '{"feature_directory":"%s"}\n' "$(json_escape "$feature_dir_value")" > "$fj"
|
||||
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
|
||||
|
||||
# Resolve feature directory. Priority:
|
||||
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__)
|
||||
# 3. Branch-name-based prefix lookup (legacy fallback)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by specify command)
|
||||
# 3. Error — no feature context available
|
||||
local feature_dir
|
||||
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
|
||||
feature_dir="$SPECIFY_FEATURE_DIRECTORY"
|
||||
# Normalize relative paths to absolute under repo root
|
||||
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
||||
# Persist to feature.json so future sessions without the env var still work
|
||||
_persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY"
|
||||
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
|
||||
# Shared, set -e-safe parser: jq -> python3 -> grep/sed. Returns empty on
|
||||
# missing/unparseable/unset so we fall through to the branch-prefix lookup.
|
||||
local _fd
|
||||
_fd=$(read_feature_json_feature_directory "$repo_root")
|
||||
if [[ -n "$_fd" ]]; then
|
||||
feature_dir="$_fd"
|
||||
# Normalize relative paths to absolute under repo root
|
||||
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
||||
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
|
||||
echo "ERROR: Failed to resolve feature directory" >&2
|
||||
else
|
||||
echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." >&2
|
||||
return 1
|
||||
fi
|
||||
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
|
||||
echo "ERROR: Failed to resolve feature directory" >&2
|
||||
else
|
||||
echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -291,7 +153,6 @@ get_feature_paths() {
|
||||
# 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"
|
||||
|
||||
@@ -57,9 +57,9 @@ while [ $i -le $# ]; do
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --json Output in JSON format"
|
||||
echo " --dry-run Compute branch name and paths without creating branches, directories, or files"
|
||||
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
|
||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
||||
echo " --dry-run Compute feature name and paths without creating directories or files"
|
||||
echo " --allow-existing-branch Reuse an existing feature directory if it already exists"
|
||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the feature"
|
||||
echo " --number N Specify branch number manually (overrides auto-detection)"
|
||||
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
echo " --help, -h Show this help message"
|
||||
@@ -113,94 +113,18 @@ get_highest_from_specs() {
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to get highest number from git branches
|
||||
get_highest_from_branches() {
|
||||
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
|
||||
}
|
||||
|
||||
# Extract the highest sequential feature number from a list of ref names (one per line).
|
||||
# Shared by get_highest_from_branches and get_highest_from_remote_refs.
|
||||
_extract_highest_number() {
|
||||
local highest=0
|
||||
while IFS= read -r name; do
|
||||
[ -z "$name" ] && continue
|
||||
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to get highest number from remote branches without fetching (side-effect-free)
|
||||
get_highest_from_remote_refs() {
|
||||
local highest=0
|
||||
|
||||
for remote in $(git remote 2>/dev/null); do
|
||||
local remote_highest
|
||||
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
|
||||
if [ "$remote_highest" -gt "$highest" ]; then
|
||||
highest=$remote_highest
|
||||
fi
|
||||
done
|
||||
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to check existing branches (local and remote) and return next available number.
|
||||
# When skip_fetch is true, queries remotes via ls-remote (read-only) instead of fetching.
|
||||
check_existing_branches() {
|
||||
local specs_dir="$1"
|
||||
local skip_fetch="${2:-false}"
|
||||
|
||||
if [ "$skip_fetch" = true ]; then
|
||||
# Side-effect-free: query remotes via ls-remote
|
||||
local highest_remote=$(get_highest_from_remote_refs)
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
if [ "$highest_remote" -gt "$highest_branch" ]; then
|
||||
highest_branch=$highest_remote
|
||||
fi
|
||||
else
|
||||
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
||||
git fetch --all --prune >/dev/null 2>&1 || true
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
fi
|
||||
|
||||
# Get highest number from ALL specs (not just matching short name)
|
||||
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
||||
|
||||
# Take the maximum of both
|
||||
local max_num=$highest_branch
|
||||
if [ "$highest_spec" -gt "$max_num" ]; then
|
||||
max_num=$highest_spec
|
||||
fi
|
||||
|
||||
# Return next number
|
||||
echo $((max_num + 1))
|
||||
}
|
||||
|
||||
# Function to clean and format a branch name
|
||||
clean_branch_name() {
|
||||
local name="$1"
|
||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||
}
|
||||
|
||||
# Resolve repository root using common.sh functions which prioritize .specify over git
|
||||
# Resolve repository root using common.sh functions which prioritize .specify
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
REPO_ROOT=$(get_repo_root)
|
||||
|
||||
# Check if git is available at this repo root (not a parent)
|
||||
if has_git; then
|
||||
HAS_GIT=true
|
||||
else
|
||||
HAS_GIT=false
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
SPECS_DIR="$REPO_ROOT/specs"
|
||||
@@ -276,23 +200,10 @@ if [ "$USE_TIMESTAMP" = true ]; then
|
||||
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
else
|
||||
# Determine branch number
|
||||
# Determine branch number from existing feature directories
|
||||
if [ -z "$BRANCH_NUMBER" ]; then
|
||||
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
|
||||
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
|
||||
elif [ "$DRY_RUN" = true ]; then
|
||||
# Dry-run without git: local spec dirs only
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
elif [ "$HAS_GIT" = true ]; then
|
||||
# Check existing branches on remotes
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
||||
else
|
||||
# Fall back to local directory check
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
fi
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
fi
|
||||
|
||||
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
|
||||
@@ -326,43 +237,13 @@ FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
||||
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
if [ "$HAS_GIT" = true ]; then
|
||||
branch_create_error=""
|
||||
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
|
||||
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
||||
# Check if branch already exists
|
||||
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
||||
if [ "$ALLOW_EXISTING" = true ]; then
|
||||
# If we're already on the branch, continue without another checkout.
|
||||
if [ "$current_branch" = "$BRANCH_NAME" ]; then
|
||||
:
|
||||
# Otherwise switch to the existing branch instead of failing.
|
||||
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
|
||||
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
|
||||
if [ -n "$switch_branch_error" ]; then
|
||||
>&2 printf '%s\n' "$switch_branch_error"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
elif [ "$USE_TIMESTAMP" = true ]; then
|
||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
|
||||
exit 1
|
||||
else
|
||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
|
||||
if [ -n "$branch_create_error" ]; then
|
||||
>&2 printf '%s\n' "$branch_create_error"
|
||||
else
|
||||
>&2 echo "Please check your git configuration and try again."
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
if [ -d "$FEATURE_DIR" ] && [ "$ALLOW_EXISTING" != true ]; then
|
||||
if [ "$USE_TIMESTAMP" = true ]; then
|
||||
>&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Rerun to get a new timestamp or use a different --short-name."
|
||||
else
|
||||
>&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Please use a different feature name or specify a different number with --number."
|
||||
fi
|
||||
else
|
||||
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
@@ -377,8 +258,12 @@ if [ "$DRY_RUN" != true ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Inform the user how to persist the feature variable in their own shell
|
||||
# Persist to .specify/feature.json so downstream commands can find the feature
|
||||
_persist_feature_json "$REPO_ROOT" "$FEATURE_DIR"
|
||||
|
||||
# Inform the user how to set feature state in their own shell
|
||||
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR" >&2
|
||||
fi
|
||||
|
||||
if $JSON_MODE; then
|
||||
@@ -409,5 +294,6 @@ else
|
||||
echo "FEATURE_NUM: $FEATURE_NUM"
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -32,11 +32,6 @@ _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature p
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
|
||||
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
fi
|
||||
|
||||
# Ensure the feature directory exists
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
@@ -75,17 +70,15 @@ if $JSON_MODE; then
|
||||
--arg impl_plan "$IMPL_PLAN" \
|
||||
--arg specs_dir "$FEATURE_DIR" \
|
||||
--arg branch "$CURRENT_BRANCH" \
|
||||
--arg has_git "$HAS_GIT" \
|
||||
'{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}'
|
||||
'{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch}'
|
||||
else
|
||||
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \
|
||||
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")"
|
||||
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s"}\n' \
|
||||
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")"
|
||||
fi
|
||||
else
|
||||
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
||||
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||
echo "SPECS_DIR: $FEATURE_DIR"
|
||||
echo "BRANCH: $CURRENT_BRANCH"
|
||||
echo "HAS_GIT: $HAS_GIT"
|
||||
fi
|
||||
|
||||
|
||||
@@ -27,12 +27,7 @@ _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature p
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
|
||||
# Validate branch
|
||||
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
fi
|
||||
|
||||
# Validate required files
|
||||
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2
|
||||
|
||||
@@ -81,11 +81,6 @@ if ($PathsOnly) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Validate branch name
|
||||
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Validate required directories and files
|
||||
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
|
||||
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"
|
||||
|
||||
@@ -24,8 +24,8 @@ function Find-SpecifyRoot {
|
||||
}
|
||||
}
|
||||
|
||||
# Get repository root, prioritizing .specify directory over git
|
||||
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
|
||||
# Get repository root, prioritizing .specify directory
|
||||
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
|
||||
function Get-RepoRoot {
|
||||
# First, look for .specify directory (spec-kit's own marker)
|
||||
$specifyRoot = Find-SpecifyRoot
|
||||
@@ -33,263 +33,81 @@ function Get-RepoRoot {
|
||||
return $specifyRoot
|
||||
}
|
||||
|
||||
# Fallback to git if no .specify found
|
||||
try {
|
||||
$result = git rev-parse --show-toplevel 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
return $result
|
||||
}
|
||||
} catch {
|
||||
# Git command failed
|
||||
}
|
||||
|
||||
# Final fallback to script location for non-git repos
|
||||
# Final fallback to script location
|
||||
# Use -LiteralPath to handle paths with wildcard characters
|
||||
return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path
|
||||
}
|
||||
|
||||
function Get-CurrentBranch {
|
||||
# First check if SPECIFY_FEATURE environment variable is set
|
||||
# Return feature name from explicit state only.
|
||||
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
|
||||
# the git extension) or implicitly via .specify/feature.json.
|
||||
if ($env:SPECIFY_FEATURE) {
|
||||
return $env:SPECIFY_FEATURE
|
||||
}
|
||||
|
||||
# Then check git if available at the spec-kit root (not parent)
|
||||
$repoRoot = Get-RepoRoot
|
||||
if (Test-HasGit) {
|
||||
# No explicit feature set - return empty to signal "unknown".
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Persist a feature_directory value to .specify/feature.json.
|
||||
# Writes only when the file is missing or the value differs from what's stored.
|
||||
function Save-FeatureJson {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||
[Parameter(Mandatory = $true)][string]$FeatureDirectory
|
||||
)
|
||||
|
||||
# Strip repo root prefix if the value is absolute and under repo root.
|
||||
# Use case-insensitive comparison on Windows only (case-sensitive filesystems elsewhere).
|
||||
$prefix = $RepoRoot + [System.IO.Path]::DirectorySeparatorChar
|
||||
if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true }
|
||||
if ($onWin) {
|
||||
$cmp = [System.StringComparison]::OrdinalIgnoreCase
|
||||
} else {
|
||||
$cmp = [System.StringComparison]::Ordinal
|
||||
}
|
||||
if ($FeatureDirectory.StartsWith($prefix, $cmp)) {
|
||||
$FeatureDirectory = $FeatureDirectory.Substring($prefix.Length)
|
||||
}
|
||||
|
||||
$fjPath = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
|
||||
|
||||
# Read current value and skip write when unchanged
|
||||
if (Test-Path -LiteralPath $fjPath -PathType Leaf) {
|
||||
try {
|
||||
$result = git -C $repoRoot rev-parse --abbrev-ref HEAD 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
return $result
|
||||
$raw = Get-Content -LiteralPath $fjPath -Raw
|
||||
$cfg = $raw | ConvertFrom-Json
|
||||
if ($cfg.feature_directory -eq $FeatureDirectory) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
# Git command failed
|
||||
# File is corrupt or unreadable - overwrite it
|
||||
}
|
||||
}
|
||||
|
||||
# For non-git repos, try to find the latest feature directory
|
||||
$specsDir = Join-Path $repoRoot "specs"
|
||||
|
||||
if (Test-Path $specsDir) {
|
||||
$latestFeature = ""
|
||||
$highest = 0
|
||||
$latestTimestamp = ""
|
||||
|
||||
Get-ChildItem -Path $specsDir -Directory | ForEach-Object {
|
||||
if ($_.Name -match '^(\d{8}-\d{6})-') {
|
||||
# Timestamp-based branch: compare lexicographically
|
||||
$ts = $matches[1]
|
||||
if ($ts -gt $latestTimestamp) {
|
||||
$latestTimestamp = $ts
|
||||
$latestFeature = $_.Name
|
||||
}
|
||||
} elseif ($_.Name -match '^(\d{3,})-') {
|
||||
$num = [long]$matches[1]
|
||||
if ($num -gt $highest) {
|
||||
$highest = $num
|
||||
# Only update if no timestamp branch found yet
|
||||
if (-not $latestTimestamp) {
|
||||
$latestFeature = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($latestFeature) {
|
||||
return $latestFeature
|
||||
}
|
||||
}
|
||||
|
||||
# Final fallback
|
||||
return "main"
|
||||
}
|
||||
|
||||
# Check if we have git available at the spec-kit root level
|
||||
# Returns true only if git is installed and the repo root is inside a git work tree
|
||||
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
|
||||
function Test-HasGit {
|
||||
# First check if git command is available (before calling Get-RepoRoot which may use git)
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||
return $false
|
||||
}
|
||||
$repoRoot = Get-RepoRoot
|
||||
# Check if .git exists (directory or file for worktrees/submodules)
|
||||
# Use -LiteralPath to handle paths with wildcard characters
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $repoRoot ".git"))) {
|
||||
return $false
|
||||
}
|
||||
# Verify it's actually a valid git work tree
|
||||
try {
|
||||
$null = git -C $repoRoot rev-parse --is-inside-work-tree 2>$null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
|
||||
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
|
||||
function Get-SpecKitEffectiveBranchName {
|
||||
param([string]$Branch)
|
||||
if ($Branch -match '^([^/]+)/([^/]+)$') {
|
||||
return $Matches[2]
|
||||
}
|
||||
return $Branch
|
||||
}
|
||||
|
||||
function Test-FeatureBranch {
|
||||
param(
|
||||
[string]$Branch,
|
||||
[bool]$HasGit = $true
|
||||
)
|
||||
|
||||
# For non-git repos, we can't enforce branch naming but still provide output
|
||||
if (-not $HasGit) {
|
||||
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
|
||||
return $true
|
||||
# Ensure .specify/ directory exists
|
||||
$specifyDir = Join-Path $RepoRoot '.specify'
|
||||
if (-not (Test-Path -LiteralPath $specifyDir -PathType Container)) {
|
||||
New-Item -ItemType Directory -Path $specifyDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$raw = $Branch
|
||||
$Branch = Get-SpecKitEffectiveBranchName $raw
|
||||
|
||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
|
||||
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
||||
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
|
||||
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
|
||||
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
|
||||
return $false
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
# True when .specify/feature.json pins an existing feature directory that matches the
|
||||
# active FEATURE_DIR from Get-FeaturePathsEnv (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks).
|
||||
function Test-FeatureJsonMatchesFeatureDir {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||
[Parameter(Mandatory = $true)][string]$ActiveFeatureDir
|
||||
)
|
||||
|
||||
$featureJson = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
|
||||
if (-not (Test-Path -LiteralPath $featureJson -PathType Leaf)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = Get-Content -LiteralPath $featureJson -Raw
|
||||
$cfg = $raw | ConvertFrom-Json
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
|
||||
$fd = $cfg.feature_directory
|
||||
if ([string]::IsNullOrWhiteSpace([string]$fd)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
if (-not [System.IO.Path]::IsPathRooted($fd)) {
|
||||
$fd = Join-Path $RepoRoot $fd
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $fd -PathType Container)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
# Resolve both paths to canonical absolute form. Prefer Resolve-Path (follows
|
||||
# symlinks and is the canonical PS way); fall back to [Path]::GetFullPath when
|
||||
# Resolve-Path can't produce a value. Mirrors the pattern used by Find-SpecifyRoot.
|
||||
$resolvedJson = Resolve-Path -LiteralPath $fd -ErrorAction SilentlyContinue
|
||||
if ($resolvedJson) {
|
||||
$normJson = $resolvedJson.Path
|
||||
} else {
|
||||
$normJson = [System.IO.Path]::GetFullPath($fd)
|
||||
}
|
||||
|
||||
$resolvedActive = Resolve-Path -LiteralPath $ActiveFeatureDir -ErrorAction SilentlyContinue
|
||||
if ($resolvedActive) {
|
||||
$normActive = $resolvedActive.Path
|
||||
} else {
|
||||
$normActive = [System.IO.Path]::GetFullPath($ActiveFeatureDir)
|
||||
}
|
||||
|
||||
# Use case-insensitive compare only on Windows; POSIX filesystems are case-sensitive.
|
||||
# PowerShell 5.1 is Windows-only and does not define $IsWindows, so treat its
|
||||
# absence as "we're on Windows".
|
||||
if ($null -ne $IsWindows) {
|
||||
$onWindows = $IsWindows
|
||||
} else {
|
||||
$onWindows = $true
|
||||
}
|
||||
|
||||
if ($onWindows) {
|
||||
$comparison = [System.StringComparison]::OrdinalIgnoreCase
|
||||
} else {
|
||||
$comparison = [System.StringComparison]::Ordinal
|
||||
}
|
||||
|
||||
return [string]::Equals($normJson, $normActive, $comparison)
|
||||
}
|
||||
|
||||
# Resolve specs/<feature-dir> by numeric/timestamp prefix (mirrors scripts/bash/common.sh find_feature_dir_by_prefix).
|
||||
function Find-FeatureDirByPrefix {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||
[Parameter(Mandatory = $true)][string]$Branch
|
||||
)
|
||||
$specsDir = Join-Path $RepoRoot 'specs'
|
||||
$branchName = Get-SpecKitEffectiveBranchName $Branch
|
||||
|
||||
$prefix = $null
|
||||
if ($branchName -match '^(\d{8}-\d{6})-') {
|
||||
$prefix = $Matches[1]
|
||||
} elseif ($branchName -match '^(\d{3,})-') {
|
||||
$prefix = $Matches[1]
|
||||
} else {
|
||||
return (Join-Path $specsDir $branchName)
|
||||
}
|
||||
|
||||
$dirMatches = @()
|
||||
if (Test-Path -LiteralPath $specsDir -PathType Container) {
|
||||
$dirMatches = @(Get-ChildItem -LiteralPath $specsDir -Filter "$prefix-*" -Directory -ErrorAction SilentlyContinue)
|
||||
}
|
||||
|
||||
if ($dirMatches.Count -eq 0) {
|
||||
return (Join-Path $specsDir $branchName)
|
||||
}
|
||||
if ($dirMatches.Count -eq 1) {
|
||||
return $dirMatches[0].FullName
|
||||
}
|
||||
$names = ($dirMatches | ForEach-Object { $_.Name }) -join ' '
|
||||
[Console]::Error.WriteLine("ERROR: Multiple spec directories found with prefix '$prefix': $names")
|
||||
[Console]::Error.WriteLine('Please ensure only one spec directory exists per prefix.')
|
||||
return $null
|
||||
}
|
||||
|
||||
# Branch-based prefix resolution; mirrors bash get_feature_paths failure (stderr + exit 1).
|
||||
function Get-FeatureDirFromBranchPrefixOrExit {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||
[Parameter(Mandatory = $true)][string]$CurrentBranch
|
||||
)
|
||||
$resolved = Find-FeatureDirByPrefix -RepoRoot $RepoRoot -Branch $CurrentBranch
|
||||
if ($null -eq $resolved) {
|
||||
[Console]::Error.WriteLine('ERROR: Failed to resolve feature directory')
|
||||
exit 1
|
||||
}
|
||||
return $resolved
|
||||
# Write feature.json
|
||||
$json = @{ feature_directory = $FeatureDirectory } | ConvertTo-Json -Compress
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($fjPath, $json, $utf8NoBom)
|
||||
}
|
||||
|
||||
function Get-FeaturePathsEnv {
|
||||
$repoRoot = Get-RepoRoot
|
||||
$currentBranch = Get-CurrentBranch
|
||||
$hasGit = Test-HasGit
|
||||
|
||||
# Resolve feature directory. Priority:
|
||||
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__)
|
||||
# 3. Branch-name-based prefix lookup (same as scripts/bash/common.sh)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by specify command)
|
||||
# 3. Error - no feature context available
|
||||
$featureJson = Join-Path $repoRoot '.specify/feature.json'
|
||||
if ($env:SPECIFY_FEATURE_DIRECTORY) {
|
||||
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY
|
||||
@@ -297,6 +115,8 @@ function Get-FeaturePathsEnv {
|
||||
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
|
||||
$featureDir = Join-Path $repoRoot $featureDir
|
||||
}
|
||||
# Persist to feature.json so future sessions without the env var still work
|
||||
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
|
||||
} elseif (Test-Path $featureJson) {
|
||||
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
|
||||
try {
|
||||
@@ -312,16 +132,17 @@ function Get-FeaturePathsEnv {
|
||||
$featureDir = Join-Path $repoRoot $featureDir
|
||||
}
|
||||
} else {
|
||||
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
REPO_ROOT = $repoRoot
|
||||
CURRENT_BRANCH = $currentBranch
|
||||
HAS_GIT = $hasGit
|
||||
FEATURE_DIR = $featureDir
|
||||
FEATURE_SPEC = Join-Path $featureDir 'spec.md'
|
||||
IMPL_PLAN = Join-Path $featureDir 'plan.md'
|
||||
|
||||
@@ -21,9 +21,9 @@ if ($Help) {
|
||||
Write-Host ""
|
||||
Write-Host "Options:"
|
||||
Write-Host " -Json Output in JSON format"
|
||||
Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files"
|
||||
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
|
||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
||||
Write-Host " -DryRun Compute feature name and paths without creating directories or files"
|
||||
Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
|
||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the feature"
|
||||
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
||||
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
Write-Host " -Help Show this help message"
|
||||
@@ -67,111 +67,17 @@ function Get-HighestNumberFromSpecs {
|
||||
return $highest
|
||||
}
|
||||
|
||||
# Extract the highest sequential feature number from a list of branch/ref names.
|
||||
# Shared by Get-HighestNumberFromBranches and Get-HighestNumberFromRemoteRefs.
|
||||
function Get-HighestNumberFromNames {
|
||||
param([string[]]$Names)
|
||||
|
||||
[long]$highest = 0
|
||||
foreach ($name in $Names) {
|
||||
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
|
||||
[long]$num = 0
|
||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||
$highest = $num
|
||||
}
|
||||
}
|
||||
}
|
||||
return $highest
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromBranches {
|
||||
param()
|
||||
|
||||
try {
|
||||
$branches = git branch -a 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $branches) {
|
||||
$cleanNames = $branches | ForEach-Object {
|
||||
$_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||
}
|
||||
return Get-HighestNumberFromNames -Names $cleanNames
|
||||
}
|
||||
} catch {
|
||||
Write-Verbose "Could not check Git branches: $_"
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromRemoteRefs {
|
||||
[long]$highest = 0
|
||||
try {
|
||||
$remotes = git remote 2>$null
|
||||
if ($remotes) {
|
||||
foreach ($remote in $remotes) {
|
||||
$env:GIT_TERMINAL_PROMPT = '0'
|
||||
$refs = git ls-remote --heads $remote 2>$null
|
||||
$env:GIT_TERMINAL_PROMPT = $null
|
||||
if ($LASTEXITCODE -eq 0 -and $refs) {
|
||||
$refNames = $refs | ForEach-Object {
|
||||
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
|
||||
} | Where-Object { $_ }
|
||||
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
|
||||
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Verbose "Could not query remote refs: $_"
|
||||
}
|
||||
return $highest
|
||||
}
|
||||
|
||||
# Return next available branch number. When SkipFetch is true, queries remotes
|
||||
# via ls-remote (read-only) instead of fetching.
|
||||
function Get-NextBranchNumber {
|
||||
param(
|
||||
[string]$SpecsDir,
|
||||
[switch]$SkipFetch
|
||||
)
|
||||
|
||||
if ($SkipFetch) {
|
||||
# Side-effect-free: query remotes via ls-remote
|
||||
$highestBranch = Get-HighestNumberFromBranches
|
||||
$highestRemote = Get-HighestNumberFromRemoteRefs
|
||||
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
|
||||
} else {
|
||||
# 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
|
||||
}
|
||||
$highestBranch = Get-HighestNumberFromBranches
|
||||
}
|
||||
|
||||
# Get highest number from ALL specs (not just matching short name)
|
||||
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
||||
|
||||
# Take the maximum of both
|
||||
$maxNum = [Math]::Max($highestBranch, $highestSpec)
|
||||
|
||||
# Return next number
|
||||
return $maxNum + 1
|
||||
}
|
||||
|
||||
function ConvertTo-CleanBranchName {
|
||||
param([string]$Name)
|
||||
|
||||
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
||||
}
|
||||
# Load common functions (includes Get-RepoRoot, Test-HasGit, Resolve-Template)
|
||||
# Load common functions (includes Get-RepoRoot and Resolve-Template)
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Use common.ps1 functions which prioritize .specify over git
|
||||
# Use common.ps1 functions which prioritize .specify
|
||||
$repoRoot = Get-RepoRoot
|
||||
|
||||
# Check if git is available at this repo root (not a parent)
|
||||
$hasGit = Test-HasGit
|
||||
|
||||
Set-Location $repoRoot
|
||||
|
||||
$specsDir = Join-Path $repoRoot 'specs'
|
||||
@@ -244,21 +150,9 @@ if ($Timestamp) {
|
||||
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
} else {
|
||||
# Determine branch number
|
||||
# Determine branch number from existing feature directories
|
||||
if ($Number -eq 0) {
|
||||
if ($DryRun -and $hasGit) {
|
||||
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
|
||||
} elseif ($DryRun) {
|
||||
# Dry-run without git: local spec dirs only
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
} elseif ($hasGit) {
|
||||
# Check existing branches on remotes
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
||||
} else {
|
||||
# Fall back to local directory check
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
}
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
}
|
||||
|
||||
$featureNum = ('{0:000}' -f $Number)
|
||||
@@ -291,58 +185,13 @@ $featureDir = Join-Path $specsDir $branchName
|
||||
$specFile = Join-Path $featureDir 'spec.md'
|
||||
|
||||
if (-not $DryRun) {
|
||||
if ($hasGit) {
|
||||
$branchCreated = $false
|
||||
$branchCreateError = ''
|
||||
try {
|
||||
$branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$branchCreated = $true
|
||||
}
|
||||
} catch {
|
||||
$branchCreateError = $_.Exception.Message
|
||||
if ((Test-Path -LiteralPath $featureDir -PathType Container) -and -not $AllowExistingBranch) {
|
||||
if ($Timestamp) {
|
||||
Write-Error "Error: Feature directory '$featureDir' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
||||
} else {
|
||||
Write-Error "Error: Feature directory '$featureDir' already exists. Please use a different feature name or specify a different number with -Number."
|
||||
}
|
||||
|
||||
if (-not $branchCreated) {
|
||||
$currentBranch = ''
|
||||
try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
|
||||
# Check if branch already exists
|
||||
$existingBranch = git branch --list $branchName 2>$null
|
||||
if ($existingBranch) {
|
||||
if ($AllowExistingBranch) {
|
||||
# If we're already on the branch, continue without another checkout.
|
||||
if ($currentBranch -eq $branchName) {
|
||||
# Already on the target branch -- nothing to do
|
||||
} else {
|
||||
# Otherwise switch to the existing branch instead of failing.
|
||||
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if ($switchBranchError) {
|
||||
Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
|
||||
} else {
|
||||
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
} elseif ($Timestamp) {
|
||||
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
||||
exit 1
|
||||
} else {
|
||||
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
if ($branchCreateError) {
|
||||
Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
|
||||
} else {
|
||||
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
|
||||
exit 1
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
||||
@@ -359,8 +208,12 @@ if (-not $DryRun) {
|
||||
}
|
||||
}
|
||||
|
||||
# Set the SPECIFY_FEATURE environment variable for the current session
|
||||
# Persist to .specify/feature.json so downstream commands can find the feature
|
||||
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $featureDir
|
||||
|
||||
# Set environment variables for the current session
|
||||
$env:SPECIFY_FEATURE = $branchName
|
||||
$env:SPECIFY_FEATURE_DIRECTORY = $featureDir
|
||||
}
|
||||
|
||||
if ($Json) {
|
||||
@@ -368,7 +221,6 @@ if ($Json) {
|
||||
BRANCH_NAME = $branchName
|
||||
SPEC_FILE = $specFile
|
||||
FEATURE_NUM = $featureNum
|
||||
HAS_GIT = $hasGit
|
||||
}
|
||||
if ($DryRun) {
|
||||
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
|
||||
@@ -378,8 +230,8 @@ if ($Json) {
|
||||
Write-Output "BRANCH_NAME: $branchName"
|
||||
Write-Output "SPEC_FILE: $specFile"
|
||||
Write-Output "FEATURE_NUM: $featureNum"
|
||||
Write-Output "HAS_GIT: $hasGit"
|
||||
if (-not $DryRun) {
|
||||
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
||||
Write-Output "SPECIFY_FEATURE set to: $branchName"
|
||||
Write-Output "SPECIFY_FEATURE_DIRECTORY set to: $featureDir"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,13 +23,6 @@ if ($Help) {
|
||||
# Get all paths and variables from common functions
|
||||
$paths = Get-FeaturePathsEnv
|
||||
|
||||
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||
if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
|
||||
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure the feature directory exists
|
||||
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
||||
|
||||
@@ -61,7 +54,6 @@ if ($Json) {
|
||||
IMPL_PLAN = $paths.IMPL_PLAN
|
||||
SPECS_DIR = $paths.FEATURE_DIR
|
||||
BRANCH = $paths.CURRENT_BRANCH
|
||||
HAS_GIT = $paths.HAS_GIT
|
||||
}
|
||||
$result | ConvertTo-Json -Compress
|
||||
} else {
|
||||
@@ -69,5 +61,4 @@ if ($Json) {
|
||||
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
|
||||
Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)"
|
||||
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
|
||||
Write-Output "HAS_GIT: $($paths.HAS_GIT)"
|
||||
}
|
||||
|
||||
@@ -16,16 +16,9 @@ if ($Help) {
|
||||
# Source common functions
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get feature paths and validate branch
|
||||
# Get feature paths
|
||||
$paths = Get-FeaturePathsEnv
|
||||
|
||||
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||
if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
|
||||
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
||||
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT
|
||||
|
||||
Reference in New Issue
Block a user