diff --git a/.gitignore b/.gitignore index 1ed573622d..0ef928eb92 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,9 @@ env/ .genreleases/ *.zip sdd-*/ + +# Local configuration (contains user-specific paths) +.specify/config.json + +# Git worktrees (nested strategy) +.worktrees/ diff --git a/.specify/scripts/bash/common.sh b/.specify/scripts/bash/common.sh new file mode 100755 index 0000000000..e8724da7d0 --- /dev/null +++ b/.specify/scripts/bash/common.sh @@ -0,0 +1,195 @@ +#!/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 + echo "$specs_dir/$branch_name" # Return something to avoid breaking the script + 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=$(find_feature_dir_by_prefix "$repo_root" "$current_branch") + + cat </dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } + +# Read a value from .specify/config.json +# Usage: read_config_value "git_mode" [default_value] +# Returns the value or default if not found +read_config_value() { + local key="$1" + local default_value="${2:-}" + local repo_root + repo_root=$(get_repo_root) + local config_file="$repo_root/.specify/config.json" + + if [[ ! -f "$config_file" ]]; then + echo "$default_value" + return + fi + + local value="" + if command -v jq &>/dev/null; then + # Use jq if available (preferred) + value=$(jq -r ".$key // empty" "$config_file" 2>/dev/null) + else + # Fallback: simple grep/sed for JSON values + # Try quoted string first: "key": "value" + value=$(grep -o "\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$config_file" 2>/dev/null | \ + sed 's/.*:[[:space:]]*"\([^"]*\)".*/\1/' | head -1) + + # If no quoted value found, try unquoted (booleans/numbers): "key": true/false/123 + if [[ -z "$value" ]]; then + value=$(grep -o "\"$key\"[[:space:]]*:[[:space:]]*[^,}\"]*" "$config_file" 2>/dev/null | \ + sed 's/.*:[[:space:]]*\([^,}]*\).*/\1/' | tr -d ' ' | head -1) + fi + fi + + if [[ -n "$value" ]]; then + echo "$value" + else + echo "$default_value" + fi +} + diff --git a/.specify/scripts/bash/configure-worktree.sh b/.specify/scripts/bash/configure-worktree.sh new file mode 100755 index 0000000000..6687facc42 --- /dev/null +++ b/.specify/scripts/bash/configure-worktree.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# Configure git worktree preferences for Spec Kit + +set -e + +# Get script directory and source common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Parse arguments +MODE="" +STRATEGY="" +CUSTOM_PATH="" +SHOW_CONFIG=false + +show_help() { + cat << 'EOF' +Usage: configure-worktree.sh [OPTIONS] + +Configure git worktree preferences for Spec Kit feature creation. + +Options: + --mode Set git mode (default: branch) + --strategy Set worktree placement strategy + --path Custom base path (required if strategy is 'custom') + --show Display current configuration + --help, -h Show this help message + +Strategies: + nested - Worktrees in .worktrees/ directory inside the repository + sibling - Worktrees as sibling directories to the repository + custom - Worktrees in a custom directory (requires --path) + +Examples: + # Enable worktree mode with nested strategy + configure-worktree.sh --mode worktree --strategy nested + + # Enable worktree mode with sibling strategy + configure-worktree.sh --mode worktree --strategy sibling + + # Enable worktree mode with custom path + configure-worktree.sh --mode worktree --strategy custom --path /tmp/worktrees + + # Switch back to branch mode + configure-worktree.sh --mode branch + + # Show current configuration + configure-worktree.sh --show +EOF +} + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --mode) + if [[ -z "$2" || "$2" == --* ]]; then + echo "Error: --mode requires a value (branch or worktree)" >&2 + exit 1 + fi + MODE="$2" + shift 2 + ;; + --strategy) + if [[ -z "$2" || "$2" == --* ]]; then + echo "Error: --strategy requires a value (nested, sibling, or custom)" >&2 + exit 1 + fi + STRATEGY="$2" + shift 2 + ;; + --path) + if [[ -z "$2" || "$2" == --* ]]; then + echo "Error: --path requires a value" >&2 + exit 1 + fi + CUSTOM_PATH="$2" + shift 2 + ;; + --show) + SHOW_CONFIG=true + shift + ;; + --help|-h) + show_help + exit 0 + ;; + *) + echo "Error: Unknown option: $1" >&2 + echo "Use --help for usage information" >&2 + exit 1 + ;; + esac +done + +# Get repository root +REPO_ROOT=$(get_repo_root) +CONFIG_FILE="$REPO_ROOT/.specify/config.json" + +# Show current configuration +if $SHOW_CONFIG; then + if [[ ! -f "$CONFIG_FILE" ]]; then + echo "No configuration file found. Using defaults:" + echo " git_mode: branch" + echo " worktree_strategy: sibling" + echo " worktree_custom_path: (none)" + else + echo "Current configuration ($CONFIG_FILE):" + echo " git_mode: $(read_config_value "git_mode" "branch")" + echo " worktree_strategy: $(read_config_value "worktree_strategy" "sibling")" + echo " worktree_custom_path: $(read_config_value "worktree_custom_path" "(none)")" + fi + exit 0 +fi + +# If no options provided, show help +if [[ -z "$MODE" && -z "$STRATEGY" && -z "$CUSTOM_PATH" ]]; then + show_help + exit 0 +fi + +# Validate mode +if [[ -n "$MODE" ]]; then + if [[ "$MODE" != "branch" && "$MODE" != "worktree" ]]; then + echo "Error: Invalid mode '$MODE'. Must be 'branch' or 'worktree'" >&2 + exit 1 + fi +fi + +# Validate strategy +if [[ -n "$STRATEGY" ]]; then + if [[ "$STRATEGY" != "nested" && "$STRATEGY" != "sibling" && "$STRATEGY" != "custom" ]]; then + echo "Error: Invalid strategy '$STRATEGY'. Must be 'nested', 'sibling', or 'custom'" >&2 + exit 1 + fi +fi + +# Validate custom path requirements +if [[ "$STRATEGY" == "custom" && -z "$CUSTOM_PATH" ]]; then + echo "Error: --path is required when strategy is 'custom'" >&2 + exit 1 +fi + +# Validate custom path is absolute +if [[ -n "$CUSTOM_PATH" ]]; then + if [[ "$CUSTOM_PATH" != /* ]]; then + echo "Error: --path must be an absolute path (got: $CUSTOM_PATH)" >&2 + exit 1 + fi + # Check if path is writable (create parent if needed) + CUSTOM_PARENT=$(dirname "$CUSTOM_PATH") + if [[ ! -d "$CUSTOM_PARENT" ]]; then + echo "Error: Parent directory does not exist: $CUSTOM_PARENT" >&2 + exit 1 + fi + if [[ ! -w "$CUSTOM_PARENT" ]]; then + echo "Error: Parent directory is not writable: $CUSTOM_PARENT" >&2 + exit 1 + fi +fi + +# Ensure .specify directory exists +mkdir -p "$REPO_ROOT/.specify" + +# Read existing config or create empty object +if [[ -f "$CONFIG_FILE" ]]; then + if command -v jq &>/dev/null; then + EXISTING_CONFIG=$(cat "$CONFIG_FILE") + else + # Without jq, we'll reconstruct the file + EXISTING_CONFIG="{}" + fi +else + EXISTING_CONFIG="{}" +fi + +# Update configuration using jq if available +if command -v jq &>/dev/null; then + # Build jq update expression + UPDATE_EXPR="." + + if [[ -n "$MODE" ]]; then + UPDATE_EXPR="$UPDATE_EXPR | .git_mode = \"$MODE\"" + fi + + if [[ -n "$STRATEGY" ]]; then + UPDATE_EXPR="$UPDATE_EXPR | .worktree_strategy = \"$STRATEGY\"" + fi + + if [[ -n "$CUSTOM_PATH" ]]; then + UPDATE_EXPR="$UPDATE_EXPR | .worktree_custom_path = \"$CUSTOM_PATH\"" + elif [[ "$STRATEGY" == "nested" || "$STRATEGY" == "sibling" ]]; then + # Clear custom path when switching to non-custom strategy + UPDATE_EXPR="$UPDATE_EXPR | .worktree_custom_path = \"\"" + fi + + echo "$EXISTING_CONFIG" | jq "$UPDATE_EXPR" > "$CONFIG_FILE" +else + # Fallback without jq: construct JSON manually + # Warn user about potential data loss + if [[ -f "$CONFIG_FILE" ]]; then + >&2 echo "[specify] Warning: jq not found. Config file will be rewritten with only worktree settings." + >&2 echo "[specify] Install jq to preserve other configuration keys." + fi + + # Read existing values + CURRENT_MODE=$(read_config_value "git_mode" "branch") + CURRENT_STRATEGY=$(read_config_value "worktree_strategy" "sibling") + CURRENT_PATH=$(read_config_value "worktree_custom_path" "") + + # Apply updates + [[ -n "$MODE" ]] && CURRENT_MODE="$MODE" + [[ -n "$STRATEGY" ]] && CURRENT_STRATEGY="$STRATEGY" + if [[ -n "$CUSTOM_PATH" ]]; then + CURRENT_PATH="$CUSTOM_PATH" + elif [[ "$STRATEGY" == "nested" || "$STRATEGY" == "sibling" ]]; then + CURRENT_PATH="" + fi + + # Write JSON manually + cat > "$CONFIG_FILE" << EOF +{ + "git_mode": "$CURRENT_MODE", + "worktree_strategy": "$CURRENT_STRATEGY", + "worktree_custom_path": "$CURRENT_PATH" +} +EOF +fi + +echo "Configuration updated:" +echo " git_mode: $(read_config_value "git_mode" "branch")" +echo " worktree_strategy: $(read_config_value "worktree_strategy" "sibling")" +custom_path=$(read_config_value "worktree_custom_path" "") +if [[ -n "$custom_path" ]]; then + echo " worktree_custom_path: $custom_path" +fi diff --git a/.specify/scripts/bash/create-new-feature.sh b/.specify/scripts/bash/create-new-feature.sh new file mode 100755 index 0000000000..113a5b65a0 --- /dev/null +++ b/.specify/scripts/bash/create-new-feature.sh @@ -0,0 +1,443 @@ +#!/usr/bin/env bash + +set -e + +# Source common functions (for read_config_value) +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +JSON_MODE=false +SHORT_NAME="" +BRANCH_NUMBER="" +ARGS=() +i=1 +while [ $i -le $# ]; do + arg="${!i}" + case "$arg" in + --json) + JSON_MODE=true + ;; + --short-name) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + 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" + ;; + --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" + ;; + --help|-h) + echo "Usage: $0 [--json] [--short-name ] [--number N] " + echo "" + echo "Options:" + echo " --json Output in JSON format" + echo " --short-name Provide a custom short name (2-4 words) for the branch" + echo " --number N Specify branch number manually (overrides auto-detection)" + echo " --help, -h Show this help message" + echo "" + echo "Examples:" + echo " $0 'Add user authentication system' --short-name 'user-auth'" + echo " $0 'Implement OAuth2 integration for API' --number 5" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac + i=$((i + 1)) +done + +FEATURE_DESCRIPTION="${ARGS[*]}" +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Usage: $0 [--json] [--short-name ] [--number N] " >&2 + exit 1 +fi + +# 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 +} + +# Function to get highest number from specs directory +get_highest_from_specs() { + local specs_dir="$1" + local highest=0 + + if [ -d "$specs_dir" ]; then + # Use nullglob to handle empty directories gracefully + local old_nullglob=$(shopt -p nullglob 2>/dev/null || echo "shopt -u nullglob") + shopt -s nullglob + 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 + eval "$old_nullglob" + fi + + echo "$highest" +} + +# Function to get highest number from git branches +get_highest_from_branches() { + local highest=0 + + # Get all branches (local and remote) + branches=$(git branch -a 2>/dev/null || echo "") + + if [ -n "$branches" ]; then + while IFS= read -r branch; do + # Clean branch name: remove leading markers and remote prefixes + clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||') + + # Extract feature number if branch matches pattern ###-* + if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then + number=$(echo "$clean_branch" | grep -o '^[0-9]\{3\}' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done <<< "$branches" + fi + + echo "$highest" +} + +# Function to check existing branches (local and remote) and return next available number +check_existing_branches() { + local specs_dir="$1" + + # Fetch all remotes to get latest branch info (suppress errors if no remotes) + git fetch --all --prune 2>/dev/null || true + + # Get highest number from ALL branches (not just matching short name) + local highest_branch=$(get_highest_from_branches) + + # 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/-$//' +} + +# Calculate worktree path based on strategy +# Usage: calculate_worktree_path +# Returns: absolute path where worktree should be created +# Naming convention: - for sibling/custom strategies +calculate_worktree_path() { + local branch_name="$1" + local repo_root="$2" + local strategy + local custom_path + local repo_name + + strategy=$(read_config_value "worktree_strategy" "sibling") + custom_path=$(read_config_value "worktree_custom_path" "") + repo_name=$(basename "$repo_root") + + case "$strategy" in + nested) + # Nested uses just branch name since it's inside the repo + echo "$repo_root/.worktrees/$branch_name" + ;; + sibling) + # Sibling uses repo_name-branch_name for clarity + echo "$(dirname "$repo_root")/${repo_name}-${branch_name}" + ;; + custom) + if [[ -n "$custom_path" ]]; then + # Custom also uses repo_name-branch_name for clarity + echo "$custom_path/${repo_name}-${branch_name}" + else + # Fallback to nested if custom path not set + echo "$repo_root/.worktrees/$branch_name" + fi + ;; + *) + # Default to nested + echo "$repo_root/.worktrees/$branch_name" + ;; + esac +} + +# Check if a git branch exists (locally or remotely) +# Usage: branch_exists +# Returns: 0 if exists, 1 if not +branch_exists() { + local branch_name="$1" + # Check local branches + if git rev-parse --verify "$branch_name" >/dev/null 2>&1; then + return 0 + fi + # Check remote branches + if git rev-parse --verify "origin/$branch_name" >/dev/null 2>&1; then + return 0 + fi + return 1 +} + +# Resolve repository root. Prefer git information when available, but fall back +# to searching for repository markers so the workflow still functions in repositories that +# were initialised with --no-git. +# Note: SCRIPT_DIR is already set at the top of this script + +if git rev-parse --show-toplevel >/dev/null 2>&1; then + REPO_ROOT=$(git rev-parse --show-toplevel) + HAS_GIT=true +else + 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 + HAS_GIT=false +fi + +cd "$REPO_ROOT" + +SPECS_DIR="$REPO_ROOT/specs" +mkdir -p "$SPECS_DIR" + +# 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 + local cleaned=$(clean_branch_name "$description") + echo "$cleaned" | 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=$(clean_branch_name "$SHORT_NAME") +else + # Generate from description with smart filtering + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") +fi + +# Determine branch number +if [ -z "$BRANCH_NUMBER" ]; then + if [ "$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 +fi + +# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) +FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") +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 + +# Check for uncommitted changes (warning only, per FR-013) +if [ "$HAS_GIT" = true ]; then + if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then + >&2 echo "[specify] Warning: Uncommitted changes in working directory will not appear in new worktree." + fi +fi + +# Check for orphaned worktrees (warning only, per FR-012) +if [ "$HAS_GIT" = true ]; then + if git worktree list --porcelain 2>/dev/null | grep -q "prunable"; then + >&2 echo "[specify] Warning: Orphaned worktree entries detected. Run 'git worktree prune' to clean up." + fi +fi + +# Determine git mode and create feature +GIT_MODE=$(read_config_value "git_mode" "branch") +CREATION_MODE="branch" +FEATURE_ROOT="$REPO_ROOT" +WORKTREE_PATH="" + +if [ "$HAS_GIT" = true ]; then + if [ "$GIT_MODE" = "worktree" ]; then + # Worktree mode + WORKTREE_PATH=$(calculate_worktree_path "$BRANCH_NAME" "$REPO_ROOT") + WORKTREE_PARENT=$(dirname "$WORKTREE_PATH") + + # Check if parent path is writable (T029) + if [[ ! -d "$WORKTREE_PARENT" ]]; then + mkdir -p "$WORKTREE_PARENT" 2>/dev/null || { + >&2 echo "[specify] Warning: Cannot write to $WORKTREE_PARENT. Falling back to branch mode." + GIT_MODE="branch" + } + elif [[ ! -w "$WORKTREE_PARENT" ]]; then + >&2 echo "[specify] Warning: Cannot write to $WORKTREE_PARENT. Falling back to branch mode." + GIT_MODE="branch" + fi + fi + + if [ "$GIT_MODE" = "worktree" ]; then + # Check if branch already exists + if branch_exists "$BRANCH_NAME"; then + # Attach worktree to existing branch (without -b flag) + if git worktree add "$WORKTREE_PATH" "$BRANCH_NAME" 2>/dev/null; then + CREATION_MODE="worktree" + FEATURE_ROOT="$WORKTREE_PATH" + else + # Fallback to branch mode + >&2 echo "[specify] Warning: Worktree creation failed. Falling back to branch mode." + >&2 echo "[specify] Note: Your current directory will switch to branch '$BRANCH_NAME'." + git checkout "$BRANCH_NAME" + CREATION_MODE="branch" + FEATURE_ROOT="$REPO_ROOT" + fi + else + # Create new branch with worktree + if git worktree add "$WORKTREE_PATH" -b "$BRANCH_NAME" 2>/dev/null; then + CREATION_MODE="worktree" + FEATURE_ROOT="$WORKTREE_PATH" + else + # Fallback to branch mode + >&2 echo "[specify] Warning: Worktree creation failed. Falling back to branch mode." + >&2 echo "[specify] Note: Your current directory will switch to branch '$BRANCH_NAME'." + git checkout -b "$BRANCH_NAME" + CREATION_MODE="branch" + FEATURE_ROOT="$REPO_ROOT" + fi + fi + else + # Standard branch mode + git checkout -b "$BRANCH_NAME" + CREATION_MODE="branch" + FEATURE_ROOT="$REPO_ROOT" + fi +else + >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" + CREATION_MODE="branch" + FEATURE_ROOT="$REPO_ROOT" +fi + +# Create feature directory and spec file +# In worktree mode, create specs in the worktree; in branch mode, create in main repo +if [ "$CREATION_MODE" = "worktree" ]; then + FEATURE_DIR="$FEATURE_ROOT/specs/$BRANCH_NAME" +else + FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +fi +mkdir -p "$FEATURE_DIR" + +TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md" +SPEC_FILE="$FEATURE_DIR/spec.md" +if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi + +# Set the SPECIFY_FEATURE environment variable for the current session +export SPECIFY_FEATURE="$BRANCH_NAME" + +if $JSON_MODE; then + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","FEATURE_ROOT":"%s","MODE":"%s"}\n' \ + "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM" "$FEATURE_ROOT" "$CREATION_MODE" +else + echo "BRANCH_NAME: $BRANCH_NAME" + echo "SPEC_FILE: $SPEC_FILE" + echo "FEATURE_NUM: $FEATURE_NUM" + echo "FEATURE_ROOT: $FEATURE_ROOT" + echo "MODE: $CREATION_MODE" + echo "SPECIFY_FEATURE environment variable set to: $BRANCH_NAME" +fi diff --git a/WORKTREE_DESIGN.md b/WORKTREE_DESIGN.md new file mode 100644 index 0000000000..8fdb03d206 --- /dev/null +++ b/WORKTREE_DESIGN.md @@ -0,0 +1,103 @@ +# Git Worktree Support Design Document + +## 1. Overview +This document outlines the design for adding `git worktree` support to Spec Kit. This feature allows users to develop multiple features simultaneously by creating separate working directories for each feature branch, rather than switching the main working copy. + +## 2. Analysis of Existing Logic +* **Feature Creation**: Currently handled by `scripts/bash/create-new-feature.sh` and `scripts/powershell/create-new-feature.ps1`. + * Logic: Calculates next branch number -> `git checkout -b` -> Creates `specs/DIR` -> Copies template. +* **Agent Interaction**: Driven by `templates/commands/specify.md`. + * Logic: Agent executes script -> Parses JSON output (`SPEC_FILE`) -> Edits file in place. + +## 3. Implementation Strategy + +### 3.1. Configuration +We need a persistent configuration to determine the user's preference and the target location for worktrees. + +* **File**: `.specify/config.json` +* **Structure**: + ```json + { + "git_mode": "branch", // Options: "branch" | "worktree" + "worktree_strategy": "sibling" // Options: "sibling" | "nested" | "custom" + "worktree_custom_path": "" // Used if strategy is "custom" (e.g., "/tmp/worktrees") + } + ``` +* **Strategies**: + * `nested`: Creates worktrees inside `/.worktrees/`. (Safest for sandboxes). + * `sibling`: Creates worktrees in `../-`. (User preferred workflow). + * `custom`: Creates worktrees in `/`. + +### 3.2. Worktree Directory Logic +The scripts will calculate the `WORKTREE_ROOT` based on the strategy. + +**Naming Convention:** +- **Nested strategy**: `/.worktrees/` (just branch name since it's inside the repo) +- **Sibling strategy**: `../-` (prefixed with repo name for clarity) +- **Custom strategy**: `/-` (prefixed with repo name for clarity) + +**Logic for `sibling` strategy:** +1. Get current repo name: `REPO_NAME=$(basename $(git rev-parse --show-toplevel))` +2. Target Dir: `../$REPO_NAME-$BRANCH_NAME` + * Example: For repo `spec-kit` with branch `001-user-auth`, creates `../spec-kit-001-user-auth` + +### 3.3. Script Modifications (`create-new-feature`) +The scripts will be updated to read `.specify/config.json`. + +**Logic Flow:** +1. Calculate `BRANCH_NAME`. +2. Check Config: + * **If Branch Mode (Default)**: + * `git checkout -b $BRANCH_NAME` + * `TARGET_ROOT="."` + * **If Worktree Mode**: + * Calculate `WORKTREE_PATH` based on config. + * `git worktree add $WORKTREE_PATH -b $BRANCH_NAME` + * `TARGET_ROOT="$WORKTREE_PATH"` +3. **Template Copying**: + * Destination becomes `$TARGET_ROOT/specs/$BRANCH_NAME/spec.md`. + * *Crucial*: The script must ensure `templates/` and `.specify/` are available in the new worktree (Git handles this automatically as they are tracked files). +4. **Output**: + * The JSON output must include `FEATURE_ROOT`: + ```json + { + "BRANCH_NAME": "005-user-auth", + "SPEC_FILE": "/Users/user/projects/005-user-auth/specs/005-user-auth/spec.md", + "FEATURE_ROOT": "/Users/user/projects/005-user-auth", + "MODE": "worktree" + } + ``` + +### 3.4. Agent Context & `SPECIFY_FEATURE` +* **Environment Variable**: `SPECIFY_FEATURE` currently holds just the branch name. +* **Slash Command Templates**: + * `templates/commands/specify.md`: Needs to instruct the Agent: + > "If `MODE` is `worktree`, the `SPEC_FILE` path is in a different directory. You must read/write that file at that absolute path." + * **Context Switching**: + * For `implement` and `plan` commands, the Agent typically runs commands like `ls` or `grep` in the current directory. + * If the worktree is in `../other-dir`, the Agent **must** change directory to `FEATURE_ROOT` at the start of its session or for every command. + * *Recommendation*: The output of `create-new-feature` should explicitly tell the agent: "I have created a new worktree at [PATH]. Please switch your working directory to [PATH] for all subsequent commands regarding this feature." + +### 3.5. Impact Analysis +* **Sandboxes**: "Sibling" worktrees (`../`) might fail in restricted container environments (DevContainers) if the parent directory isn't mounted. + * *Mitigation*: Default to `nested` or `branch` if detection fails, or simply fail with a clear error message. +* **Agent Confusion**: High risk. The Agent must be explicitly told to `cd`. + +## 4. Proposed Implementation Steps + +1. **Phase 1: Foundation** + * Update `.gitignore` to exclude `.worktrees/` (for nested mode). + * Create helper function in scripts to read JSON config. + +2. **Phase 2: Script Logic** + * Modify `create-new-feature.sh` and `create-new-feature.ps1` to implement the flexible "Worktree Logic". + +3. **Phase 3: Template Updates** + * Update `specify.md` to instruct the Agent to switch directories if a worktree path is returned. + +4. **Phase 4: User Interface** + * Add a command to `specify` CLI or a script to set the config. + * Example: `scripts/bash/configure-worktree.sh --mode worktree --location sibling` + +## 5. Rollback Plan +* If `git worktree add` fails (e.g., path permission denied), fall back to standard branch creation with a warning. \ No newline at end of file diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 2c3165e41d..e8724da7d0 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -154,3 +154,42 @@ EOF check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } +# Read a value from .specify/config.json +# Usage: read_config_value "git_mode" [default_value] +# Returns the value or default if not found +read_config_value() { + local key="$1" + local default_value="${2:-}" + local repo_root + repo_root=$(get_repo_root) + local config_file="$repo_root/.specify/config.json" + + if [[ ! -f "$config_file" ]]; then + echo "$default_value" + return + fi + + local value="" + if command -v jq &>/dev/null; then + # Use jq if available (preferred) + value=$(jq -r ".$key // empty" "$config_file" 2>/dev/null) + else + # Fallback: simple grep/sed for JSON values + # Try quoted string first: "key": "value" + value=$(grep -o "\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$config_file" 2>/dev/null | \ + sed 's/.*:[[:space:]]*"\([^"]*\)".*/\1/' | head -1) + + # If no quoted value found, try unquoted (booleans/numbers): "key": true/false/123 + if [[ -z "$value" ]]; then + value=$(grep -o "\"$key\"[[:space:]]*:[[:space:]]*[^,}\"]*" "$config_file" 2>/dev/null | \ + sed 's/.*:[[:space:]]*\([^,}]*\).*/\1/' | tr -d ' ' | head -1) + fi + fi + + if [[ -n "$value" ]]; then + echo "$value" + else + echo "$default_value" + fi +} + diff --git a/scripts/bash/configure-worktree.sh b/scripts/bash/configure-worktree.sh new file mode 100755 index 0000000000..6687facc42 --- /dev/null +++ b/scripts/bash/configure-worktree.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# Configure git worktree preferences for Spec Kit + +set -e + +# Get script directory and source common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Parse arguments +MODE="" +STRATEGY="" +CUSTOM_PATH="" +SHOW_CONFIG=false + +show_help() { + cat << 'EOF' +Usage: configure-worktree.sh [OPTIONS] + +Configure git worktree preferences for Spec Kit feature creation. + +Options: + --mode Set git mode (default: branch) + --strategy Set worktree placement strategy + --path Custom base path (required if strategy is 'custom') + --show Display current configuration + --help, -h Show this help message + +Strategies: + nested - Worktrees in .worktrees/ directory inside the repository + sibling - Worktrees as sibling directories to the repository + custom - Worktrees in a custom directory (requires --path) + +Examples: + # Enable worktree mode with nested strategy + configure-worktree.sh --mode worktree --strategy nested + + # Enable worktree mode with sibling strategy + configure-worktree.sh --mode worktree --strategy sibling + + # Enable worktree mode with custom path + configure-worktree.sh --mode worktree --strategy custom --path /tmp/worktrees + + # Switch back to branch mode + configure-worktree.sh --mode branch + + # Show current configuration + configure-worktree.sh --show +EOF +} + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --mode) + if [[ -z "$2" || "$2" == --* ]]; then + echo "Error: --mode requires a value (branch or worktree)" >&2 + exit 1 + fi + MODE="$2" + shift 2 + ;; + --strategy) + if [[ -z "$2" || "$2" == --* ]]; then + echo "Error: --strategy requires a value (nested, sibling, or custom)" >&2 + exit 1 + fi + STRATEGY="$2" + shift 2 + ;; + --path) + if [[ -z "$2" || "$2" == --* ]]; then + echo "Error: --path requires a value" >&2 + exit 1 + fi + CUSTOM_PATH="$2" + shift 2 + ;; + --show) + SHOW_CONFIG=true + shift + ;; + --help|-h) + show_help + exit 0 + ;; + *) + echo "Error: Unknown option: $1" >&2 + echo "Use --help for usage information" >&2 + exit 1 + ;; + esac +done + +# Get repository root +REPO_ROOT=$(get_repo_root) +CONFIG_FILE="$REPO_ROOT/.specify/config.json" + +# Show current configuration +if $SHOW_CONFIG; then + if [[ ! -f "$CONFIG_FILE" ]]; then + echo "No configuration file found. Using defaults:" + echo " git_mode: branch" + echo " worktree_strategy: sibling" + echo " worktree_custom_path: (none)" + else + echo "Current configuration ($CONFIG_FILE):" + echo " git_mode: $(read_config_value "git_mode" "branch")" + echo " worktree_strategy: $(read_config_value "worktree_strategy" "sibling")" + echo " worktree_custom_path: $(read_config_value "worktree_custom_path" "(none)")" + fi + exit 0 +fi + +# If no options provided, show help +if [[ -z "$MODE" && -z "$STRATEGY" && -z "$CUSTOM_PATH" ]]; then + show_help + exit 0 +fi + +# Validate mode +if [[ -n "$MODE" ]]; then + if [[ "$MODE" != "branch" && "$MODE" != "worktree" ]]; then + echo "Error: Invalid mode '$MODE'. Must be 'branch' or 'worktree'" >&2 + exit 1 + fi +fi + +# Validate strategy +if [[ -n "$STRATEGY" ]]; then + if [[ "$STRATEGY" != "nested" && "$STRATEGY" != "sibling" && "$STRATEGY" != "custom" ]]; then + echo "Error: Invalid strategy '$STRATEGY'. Must be 'nested', 'sibling', or 'custom'" >&2 + exit 1 + fi +fi + +# Validate custom path requirements +if [[ "$STRATEGY" == "custom" && -z "$CUSTOM_PATH" ]]; then + echo "Error: --path is required when strategy is 'custom'" >&2 + exit 1 +fi + +# Validate custom path is absolute +if [[ -n "$CUSTOM_PATH" ]]; then + if [[ "$CUSTOM_PATH" != /* ]]; then + echo "Error: --path must be an absolute path (got: $CUSTOM_PATH)" >&2 + exit 1 + fi + # Check if path is writable (create parent if needed) + CUSTOM_PARENT=$(dirname "$CUSTOM_PATH") + if [[ ! -d "$CUSTOM_PARENT" ]]; then + echo "Error: Parent directory does not exist: $CUSTOM_PARENT" >&2 + exit 1 + fi + if [[ ! -w "$CUSTOM_PARENT" ]]; then + echo "Error: Parent directory is not writable: $CUSTOM_PARENT" >&2 + exit 1 + fi +fi + +# Ensure .specify directory exists +mkdir -p "$REPO_ROOT/.specify" + +# Read existing config or create empty object +if [[ -f "$CONFIG_FILE" ]]; then + if command -v jq &>/dev/null; then + EXISTING_CONFIG=$(cat "$CONFIG_FILE") + else + # Without jq, we'll reconstruct the file + EXISTING_CONFIG="{}" + fi +else + EXISTING_CONFIG="{}" +fi + +# Update configuration using jq if available +if command -v jq &>/dev/null; then + # Build jq update expression + UPDATE_EXPR="." + + if [[ -n "$MODE" ]]; then + UPDATE_EXPR="$UPDATE_EXPR | .git_mode = \"$MODE\"" + fi + + if [[ -n "$STRATEGY" ]]; then + UPDATE_EXPR="$UPDATE_EXPR | .worktree_strategy = \"$STRATEGY\"" + fi + + if [[ -n "$CUSTOM_PATH" ]]; then + UPDATE_EXPR="$UPDATE_EXPR | .worktree_custom_path = \"$CUSTOM_PATH\"" + elif [[ "$STRATEGY" == "nested" || "$STRATEGY" == "sibling" ]]; then + # Clear custom path when switching to non-custom strategy + UPDATE_EXPR="$UPDATE_EXPR | .worktree_custom_path = \"\"" + fi + + echo "$EXISTING_CONFIG" | jq "$UPDATE_EXPR" > "$CONFIG_FILE" +else + # Fallback without jq: construct JSON manually + # Warn user about potential data loss + if [[ -f "$CONFIG_FILE" ]]; then + >&2 echo "[specify] Warning: jq not found. Config file will be rewritten with only worktree settings." + >&2 echo "[specify] Install jq to preserve other configuration keys." + fi + + # Read existing values + CURRENT_MODE=$(read_config_value "git_mode" "branch") + CURRENT_STRATEGY=$(read_config_value "worktree_strategy" "sibling") + CURRENT_PATH=$(read_config_value "worktree_custom_path" "") + + # Apply updates + [[ -n "$MODE" ]] && CURRENT_MODE="$MODE" + [[ -n "$STRATEGY" ]] && CURRENT_STRATEGY="$STRATEGY" + if [[ -n "$CUSTOM_PATH" ]]; then + CURRENT_PATH="$CUSTOM_PATH" + elif [[ "$STRATEGY" == "nested" || "$STRATEGY" == "sibling" ]]; then + CURRENT_PATH="" + fi + + # Write JSON manually + cat > "$CONFIG_FILE" << EOF +{ + "git_mode": "$CURRENT_MODE", + "worktree_strategy": "$CURRENT_STRATEGY", + "worktree_custom_path": "$CURRENT_PATH" +} +EOF +fi + +echo "Configuration updated:" +echo " git_mode: $(read_config_value "git_mode" "branch")" +echo " worktree_strategy: $(read_config_value "worktree_strategy" "sibling")" +custom_path=$(read_config_value "worktree_custom_path" "") +if [[ -n "$custom_path" ]]; then + echo " worktree_custom_path: $custom_path" +fi diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh old mode 100644 new mode 100755 index c40cfd77f0..5df6a1ab74 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -2,6 +2,10 @@ set -e +# Source common functions (for read_config_value) +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + JSON_MODE=false SHORT_NAME="" BRANCH_NUMBER="" @@ -84,8 +88,11 @@ find_repo_root() { get_highest_from_specs() { local specs_dir="$1" local highest=0 - + if [ -d "$specs_dir" ]; then + # Use nullglob to handle empty directories gracefully + local old_nullglob=$(shopt -p nullglob 2>/dev/null || echo "shopt -u nullglob") + shopt -s nullglob for dir in "$specs_dir"/*; do [ -d "$dir" ] || continue dirname=$(basename "$dir") @@ -95,8 +102,9 @@ get_highest_from_specs() { highest=$number fi done + eval "$old_nullglob" fi - + echo "$highest" } @@ -155,10 +163,66 @@ clean_branch_name() { echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' } +# Calculate worktree path based on strategy +# Usage: calculate_worktree_path +# Returns: absolute path where worktree should be created +# Naming convention: - for sibling/custom strategies +calculate_worktree_path() { + local branch_name="$1" + local repo_root="$2" + local strategy + local custom_path + local repo_name + + strategy=$(read_config_value "worktree_strategy" "sibling") + custom_path=$(read_config_value "worktree_custom_path" "") + repo_name=$(basename "$repo_root") + + case "$strategy" in + nested) + # Nested uses just branch name since it's inside the repo + echo "$repo_root/.worktrees/$branch_name" + ;; + sibling) + # Sibling uses repo_name-branch_name for clarity + echo "$(dirname "$repo_root")/${repo_name}-${branch_name}" + ;; + custom) + if [[ -n "$custom_path" ]]; then + # Custom also uses repo_name-branch_name for clarity + echo "$custom_path/${repo_name}-${branch_name}" + else + # Fallback to nested if custom path not set + echo "$repo_root/.worktrees/$branch_name" + fi + ;; + *) + # Default to nested + echo "$repo_root/.worktrees/$branch_name" + ;; + esac +} + +# Check if a git branch exists (locally or remotely) +# Usage: branch_exists +# Returns: 0 if exists, 1 if not +branch_exists() { + local branch_name="$1" + # Check local branches + if git rev-parse --verify "$branch_name" >/dev/null 2>&1; then + return 0 + fi + # Check remote branches + if git rev-parse --verify "origin/$branch_name" >/dev/null 2>&1; then + return 0 + fi + return 1 +} + # Resolve repository root. Prefer git information when available, but fall back # to searching for repository markers so the workflow still functions in repositories that # were initialised with --no-git. -SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Note: SCRIPT_DIR is already set at the top of this script if git rev-parse --show-toplevel >/dev/null 2>&1; then REPO_ROOT=$(git rev-parse --show-toplevel) @@ -271,13 +335,92 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" fi +# Check for uncommitted changes (warning only, per FR-013) +if [ "$HAS_GIT" = true ]; then + if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then + >&2 echo "[specify] Warning: Uncommitted changes in working directory will not appear in new worktree." + fi +fi + +# Check for orphaned worktrees (warning only, per FR-012) if [ "$HAS_GIT" = true ]; then - git checkout -b "$BRANCH_NAME" + if git worktree list --porcelain 2>/dev/null | grep -q "prunable"; then + >&2 echo "[specify] Warning: Orphaned worktree entries detected. Run 'git worktree prune' to clean up." + fi +fi + +# Determine git mode and create feature +GIT_MODE=$(read_config_value "git_mode" "branch") +CREATION_MODE="branch" +FEATURE_ROOT="$REPO_ROOT" +WORKTREE_PATH="" + +if [ "$HAS_GIT" = true ]; then + if [ "$GIT_MODE" = "worktree" ]; then + # Worktree mode + WORKTREE_PATH=$(calculate_worktree_path "$BRANCH_NAME" "$REPO_ROOT") + WORKTREE_PARENT=$(dirname "$WORKTREE_PATH") + + # Check if parent path is writable (T029) + if [[ ! -d "$WORKTREE_PARENT" ]]; then + mkdir -p "$WORKTREE_PARENT" 2>/dev/null || { + >&2 echo "[specify] Warning: Cannot write to $WORKTREE_PARENT. Falling back to branch mode." + GIT_MODE="branch" + } + elif [[ ! -w "$WORKTREE_PARENT" ]]; then + >&2 echo "[specify] Warning: Cannot write to $WORKTREE_PARENT. Falling back to branch mode." + GIT_MODE="branch" + fi + fi + + if [ "$GIT_MODE" = "worktree" ]; then + # Check if branch already exists + if branch_exists "$BRANCH_NAME"; then + # Attach worktree to existing branch (without -b flag) + if git worktree add "$WORKTREE_PATH" "$BRANCH_NAME" 2>/dev/null; then + CREATION_MODE="worktree" + FEATURE_ROOT="$WORKTREE_PATH" + else + # Fallback to branch mode + >&2 echo "[specify] Warning: Worktree creation failed. Falling back to branch mode." + >&2 echo "[specify] Note: Your current directory will switch to branch '$BRANCH_NAME'." + git checkout "$BRANCH_NAME" + CREATION_MODE="branch" + FEATURE_ROOT="$REPO_ROOT" + fi + else + # Create new branch with worktree + if git worktree add "$WORKTREE_PATH" -b "$BRANCH_NAME" 2>/dev/null; then + CREATION_MODE="worktree" + FEATURE_ROOT="$WORKTREE_PATH" + else + # Fallback to branch mode + >&2 echo "[specify] Warning: Worktree creation failed. Falling back to branch mode." + >&2 echo "[specify] Note: Your current directory will switch to branch '$BRANCH_NAME'." + git checkout -b "$BRANCH_NAME" + CREATION_MODE="branch" + FEATURE_ROOT="$REPO_ROOT" + fi + fi + else + # Standard branch mode + git checkout -b "$BRANCH_NAME" + CREATION_MODE="branch" + FEATURE_ROOT="$REPO_ROOT" + fi else >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" + CREATION_MODE="branch" + FEATURE_ROOT="$REPO_ROOT" fi -FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +# Create feature directory and spec file +# In worktree mode, create specs in the worktree; in branch mode, create in main repo +if [ "$CREATION_MODE" = "worktree" ]; then + FEATURE_DIR="$FEATURE_ROOT/specs/$BRANCH_NAME" +else + FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +fi mkdir -p "$FEATURE_DIR" TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md" @@ -288,10 +431,13 @@ if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE" export SPECIFY_FEATURE="$BRANCH_NAME" if $JSON_MODE; then - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM" + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","FEATURE_ROOT":"%s","MODE":"%s"}\n' \ + "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM" "$FEATURE_ROOT" "$CREATION_MODE" else echo "BRANCH_NAME: $BRANCH_NAME" echo "SPEC_FILE: $SPEC_FILE" echo "FEATURE_NUM: $FEATURE_NUM" + echo "FEATURE_ROOT: $FEATURE_ROOT" + echo "MODE: $CREATION_MODE" echo "SPECIFY_FEATURE environment variable set to: $BRANCH_NAME" fi diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index b0be273545..d3a558c3c9 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -135,3 +135,35 @@ function Test-DirHasFiles { } } +# Read a value from .specify/config.json +# Usage: Get-ConfigValue -Key "git_mode" [-Default "branch"] +# Returns the value or default if not found +function Get-ConfigValue { + param( + [Parameter(Mandatory = $true)] + [string]$Key, + [string]$Default = "" + ) + + $repoRoot = Get-RepoRoot + $configFile = Join-Path $repoRoot ".specify/config.json" + + if (-not (Test-Path $configFile)) { + return $Default + } + + try { + $config = Get-Content $configFile -Raw | ConvertFrom-Json + $value = $config.$Key + + if ($null -ne $value -and $value -ne "") { + return $value + } + return $Default + } + catch { + Write-Verbose "Failed to read config file: $_" + return $Default + } +} + diff --git a/scripts/powershell/configure-worktree.ps1 b/scripts/powershell/configure-worktree.ps1 new file mode 100644 index 0000000000..ab4012d10a --- /dev/null +++ b/scripts/powershell/configure-worktree.ps1 @@ -0,0 +1,173 @@ +#!/usr/bin/env pwsh +# Configure git worktree preferences for Spec Kit + +[CmdletBinding()] +param( + [ValidateSet("branch", "worktree")] + [string]$Mode, + + [ValidateSet("nested", "sibling", "custom")] + [string]$Strategy, + + [string]$Path, + + [switch]$Show, + + [switch]$Help +) + +$ErrorActionPreference = 'Stop' + +# Import common functions +. (Join-Path $PSScriptRoot "common.ps1") + +function Show-Help { + Write-Host @" +Usage: configure-worktree.ps1 [OPTIONS] + +Configure git worktree preferences for Spec Kit feature creation. + +Options: + -Mode Set git mode (default: branch) + -Strategy Set worktree placement strategy + -Path Custom base path (required if strategy is 'custom') + -Show Display current configuration + -Help Show this help message + +Strategies: + nested - Worktrees in .worktrees/ directory inside the repository + sibling - Worktrees as sibling directories to the repository + custom - Worktrees in a custom directory (requires -Path) + +Examples: + # Enable worktree mode with nested strategy + ./configure-worktree.ps1 -Mode worktree -Strategy nested + + # Enable worktree mode with sibling strategy + ./configure-worktree.ps1 -Mode worktree -Strategy sibling + + # Enable worktree mode with custom path + ./configure-worktree.ps1 -Mode worktree -Strategy custom -Path /tmp/worktrees + + # Switch back to branch mode + ./configure-worktree.ps1 -Mode branch + + # Show current configuration + ./configure-worktree.ps1 -Show +"@ +} + +# Show help if requested +if ($Help) { + Show-Help + exit 0 +} + +# Get repository root and config file path +$repoRoot = Get-RepoRoot +$configFile = Join-Path $repoRoot ".specify/config.json" + +# Show current configuration +if ($Show) { + if (-not (Test-Path $configFile)) { + Write-Host "No configuration file found. Using defaults:" + Write-Host " git_mode: branch" + Write-Host " worktree_strategy: sibling" + Write-Host " worktree_custom_path: (none)" + } + else { + Write-Host "Current configuration ($configFile):" + Write-Host " git_mode: $(Get-ConfigValue -Key 'git_mode' -Default 'branch')" + Write-Host " worktree_strategy: $(Get-ConfigValue -Key 'worktree_strategy' -Default 'sibling')" + $customPath = Get-ConfigValue -Key 'worktree_custom_path' -Default '' + if ($customPath) { + Write-Host " worktree_custom_path: $customPath" + } + else { + Write-Host " worktree_custom_path: (none)" + } + } + exit 0 +} + +# If no options provided, show help +if (-not $Mode -and -not $Strategy -and -not $Path) { + Show-Help + exit 0 +} + +# Validate custom path requirements +if ($Strategy -eq "custom" -and -not $Path) { + Write-Error "Error: -Path is required when strategy is 'custom'" + exit 1 +} + +# Validate custom path is absolute +if ($Path) { + if (-not [System.IO.Path]::IsPathRooted($Path)) { + Write-Error "Error: -Path must be an absolute path (got: $Path)" + exit 1 + } + # Check if parent directory exists and is writable + $parentPath = Split-Path $Path -Parent + if (-not (Test-Path $parentPath)) { + Write-Error "Error: Parent directory does not exist: $parentPath" + exit 1 + } + # Test writability by attempting to create a temp file + try { + $testFile = Join-Path $parentPath ".specify-write-test-$(Get-Random)" + New-Item -ItemType File -Path $testFile -Force | Out-Null + Remove-Item $testFile -Force + } + catch { + Write-Error "Error: Parent directory is not writable: $parentPath" + exit 1 + } +} + +# Ensure .specify directory exists +$specifyDir = Join-Path $repoRoot ".specify" +if (-not (Test-Path $specifyDir)) { + New-Item -ItemType Directory -Path $specifyDir -Force | Out-Null +} + +# Read existing config or create empty object +$config = @{} +if (Test-Path $configFile) { + try { + $config = Get-Content $configFile -Raw | ConvertFrom-Json -AsHashtable + } + catch { + Write-Verbose "Could not parse existing config, starting fresh" + $config = @{} + } +} + +# Update configuration +if ($Mode) { + $config['git_mode'] = $Mode +} + +if ($Strategy) { + $config['worktree_strategy'] = $Strategy +} + +if ($Path) { + $config['worktree_custom_path'] = $Path +} +elseif ($Strategy -eq "nested" -or $Strategy -eq "sibling") { + # Clear custom path when switching to non-custom strategy + $config['worktree_custom_path'] = "" +} + +# Write configuration +$config | ConvertTo-Json | Set-Content $configFile -Encoding UTF8 + +Write-Host "Configuration updated:" +Write-Host " git_mode: $(Get-ConfigValue -Key 'git_mode' -Default 'branch')" +Write-Host " worktree_strategy: $(Get-ConfigValue -Key 'worktree_strategy' -Default 'sibling')" +$customPath = Get-ConfigValue -Key 'worktree_custom_path' -Default '' +if ($customPath) { + Write-Host " worktree_custom_path: $customPath" +} diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 2f0172e35d..b2c5168974 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -11,6 +11,9 @@ param( ) $ErrorActionPreference = 'Stop' +# Import common functions (for Get-ConfigValue) +. (Join-Path $PSScriptRoot "common.ps1") + # Show help if requested if ($Help) { Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] [-Number N] " @@ -126,9 +129,72 @@ function Get-NextBranchNumber { function ConvertTo-CleanBranchName { param([string]$Name) - + return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' } + +# Calculate worktree path based on strategy +# Naming convention: - for sibling/custom strategies +function Get-WorktreePath { + param( + [string]$BranchName, + [string]$RepoRoot + ) + + $strategy = Get-ConfigValue -Key "worktree_strategy" -Default "sibling" + $customPath = Get-ConfigValue -Key "worktree_custom_path" -Default "" + $repoName = Split-Path $RepoRoot -Leaf + + switch ($strategy) { + "nested" { + # Nested uses just branch name since it's inside the repo + return Join-Path $RepoRoot ".worktrees/$BranchName" + } + "sibling" { + # Sibling uses repo_name-branch_name for clarity + return Join-Path (Split-Path $RepoRoot -Parent) "$repoName-$BranchName" + } + "custom" { + if ($customPath) { + # Custom also uses repo_name-branch_name for clarity + return Join-Path $customPath "$repoName-$BranchName" + } + else { + # Fallback to nested if custom path not set + return Join-Path $RepoRoot ".worktrees/$BranchName" + } + } + default { + return Join-Path $RepoRoot ".worktrees/$BranchName" + } + } +} + +# Check if a git branch exists (locally or remotely) +function Test-BranchExists { + param([string]$BranchName) + + # Check local branches + try { + git rev-parse --verify $BranchName 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + return $true + } + } + catch { } + + # Check remote branches + try { + git rev-parse --verify "origin/$BranchName" 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + return $true + } + } + catch { } + + return $false +} + $fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot) if (-not $fallbackRoot) { Write-Error "Error: Could not determine repository root. Please run this script from within the repository." @@ -241,43 +307,159 @@ if ($branchName.Length -gt $maxBranchLength) { Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" } +# Check for uncommitted changes (warning only, per FR-013) if ($hasGit) { - try { - git checkout -b $branchName | Out-Null - } catch { - Write-Warning "Failed to create git branch: $branchName" + $status = git status --porcelain 2>$null + if ($status) { + Write-Warning "[specify] Warning: Uncommitted changes in working directory will not appear in new worktree." } -} else { +} + +# Check for orphaned worktrees (warning only, per FR-012) +if ($hasGit) { + $worktreeInfo = git worktree list --porcelain 2>$null + if ($worktreeInfo -match "prunable") { + Write-Warning "[specify] Warning: Orphaned worktree entries detected. Run 'git worktree prune' to clean up." + } +} + +# Determine git mode and create feature +$gitMode = Get-ConfigValue -Key "git_mode" -Default "branch" +$creationMode = "branch" +$featureRoot = $repoRoot +$worktreePath = "" + +if ($hasGit) { + if ($gitMode -eq "worktree") { + # Worktree mode + $worktreePath = Get-WorktreePath -BranchName $branchName -RepoRoot $repoRoot + $worktreeParent = Split-Path $worktreePath -Parent + + # Check if parent path is writable (T033) + if (-not (Test-Path $worktreeParent)) { + try { + New-Item -ItemType Directory -Path $worktreeParent -Force -ErrorAction Stop | Out-Null + } + catch { + Write-Warning "[specify] Warning: Cannot write to $worktreeParent. Falling back to branch mode." + $gitMode = "branch" + } + } + else { + # Test writability by attempting to create a temp file + try { + $testFile = Join-Path $worktreeParent ".specify-write-test-$(Get-Random)" + New-Item -ItemType File -Path $testFile -Force -ErrorAction Stop | Out-Null + Remove-Item $testFile -Force + } + catch { + Write-Warning "[specify] Warning: Cannot write to $worktreeParent. Falling back to branch mode." + $gitMode = "branch" + } + } + } + + if ($gitMode -eq "worktree") { + # Check if branch already exists + if (Test-BranchExists -BranchName $branchName) { + # Attach worktree to existing branch (without -b flag) + try { + git worktree add $worktreePath $branchName 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + $creationMode = "worktree" + $featureRoot = $worktreePath + } + else { + throw "Worktree creation failed" + } + } + catch { + # Fallback to branch mode + Write-Warning "[specify] Warning: Worktree creation failed. Falling back to branch mode." + Write-Warning "[specify] Note: Your current directory will switch to branch '$branchName'." + git checkout $branchName | Out-Null + $creationMode = "branch" + $featureRoot = $repoRoot + } + } + else { + # Create new branch with worktree + try { + git worktree add $worktreePath -b $branchName 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + $creationMode = "worktree" + $featureRoot = $worktreePath + } + else { + throw "Worktree creation failed" + } + } + catch { + # Fallback to branch mode + Write-Warning "[specify] Warning: Worktree creation failed. Falling back to branch mode." + Write-Warning "[specify] Note: Your current directory will switch to branch '$branchName'." + git checkout -b $branchName | Out-Null + $creationMode = "branch" + $featureRoot = $repoRoot + } + } + } + else { + # Standard branch mode + try { + git checkout -b $branchName | Out-Null + } + catch { + Write-Warning "Failed to create git branch: $branchName" + } + $creationMode = "branch" + $featureRoot = $repoRoot + } +} +else { Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" + $creationMode = "branch" + $featureRoot = $repoRoot } -$featureDir = Join-Path $specsDir $branchName +# Create feature directory and spec file +# In worktree mode, create specs in the worktree; in branch mode, create in main repo +if ($creationMode -eq "worktree") { + $featureDir = Join-Path $featureRoot "specs/$branchName" +} +else { + $featureDir = Join-Path $specsDir $branchName +} New-Item -ItemType Directory -Path $featureDir -Force | Out-Null $template = Join-Path $repoRoot '.specify/templates/spec-template.md' $specFile = Join-Path $featureDir 'spec.md' -if (Test-Path $template) { - Copy-Item $template $specFile -Force -} else { - New-Item -ItemType File -Path $specFile | Out-Null +if (Test-Path $template) { + Copy-Item $template $specFile -Force +} +else { + New-Item -ItemType File -Path $specFile | Out-Null } # Set the SPECIFY_FEATURE environment variable for the current session $env:SPECIFY_FEATURE = $branchName if ($Json) { - $obj = [PSCustomObject]@{ - BRANCH_NAME = $branchName - SPEC_FILE = $specFile - FEATURE_NUM = $featureNum - HAS_GIT = $hasGit + $obj = [PSCustomObject]@{ + BRANCH_NAME = $branchName + SPEC_FILE = $specFile + FEATURE_NUM = $featureNum + FEATURE_ROOT = $featureRoot + MODE = $creationMode } $obj | ConvertTo-Json -Compress -} else { +} +else { Write-Output "BRANCH_NAME: $branchName" Write-Output "SPEC_FILE: $specFile" Write-Output "FEATURE_NUM: $featureNum" - Write-Output "HAS_GIT: $hasGit" + Write-Output "FEATURE_ROOT: $featureRoot" + Write-Output "MODE: $creationMode" Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" } diff --git a/specs/001-git-worktrees/CHANGELOG.md b/specs/001-git-worktrees/CHANGELOG.md new file mode 100644 index 0000000000..9a7b777e11 --- /dev/null +++ b/specs/001-git-worktrees/CHANGELOG.md @@ -0,0 +1,262 @@ +# Git Worktree Support - Feature Changelog + +**Feature Branch**: `001-git-worktrees` +**Date**: 2026-01-16 +**AI Disclosure**: This feature was developed with assistance from Claude Code (Claude Opus 4.5). + +## Overview + +This feature adds comprehensive git worktree support to Spec Kit, allowing users to develop multiple features simultaneously in parallel directories rather than switching branches in a single working copy. + +## Summary of Changes + +### New CLI Options for `specify init` + +| Option | Values | Default | Description | +|--------|--------|---------|-------------| +| `--git-mode` | `branch` / `worktree` | `branch` | Git workflow mode for feature development | +| `--worktree-strategy` | `sibling` / `nested` / `custom` | `sibling` | Where worktrees are created | +| `--worktree-path` | absolute path | - | Custom base path (required if strategy is `custom`) | + +### Usage Examples + +```bash +# Interactive mode (prompts for all options) +specify init my-project --ai claude + +# Explicit worktree mode with sibling strategy +specify init my-project --ai claude --git-mode worktree --worktree-strategy sibling + +# Worktree mode with nested strategy (inside .worktrees/) +specify init my-project --git-mode worktree --worktree-strategy nested + +# Worktree mode with custom path +specify init my-project --git-mode worktree --worktree-strategy custom --worktree-path /tmp/worktrees +``` + +## Feature Details + +### 1. Worktree Naming Convention + +Worktree directories use a clear naming convention that includes the repository name: + +| Strategy | Directory Format | Example | +|----------|-----------------|---------| +| **nested** | `/.worktrees/` | `spec-kit/.worktrees/001-user-auth` | +| **sibling** | `../-` | `../spec-kit-001-user-auth` | +| **custom** | `/-` | `/tmp/worktrees/spec-kit-001-user-auth` | + +### 2. Interactive Selection Flow + +When options aren't provided via CLI, users get interactive arrow-key selection menus: + +1. **AI Assistant Selection** (existing) +2. **Script Type Selection** (existing) +3. **Git Workflow Selection** (new) - Choose between `branch` or `worktree` +4. **Worktree Strategy Selection** (new, if worktree mode) - Choose `sibling`, `nested`, or `custom` +5. **Custom Path Prompt** (new, if custom strategy) + +### 3. Configuration Storage + +Settings are persisted to `.specify/config.json`: + +```json +{ + "git_mode": "worktree", + "worktree_strategy": "sibling", + "worktree_custom_path": "" +} +``` + +This configuration is read by `create-new-feature.sh` and `create-new-feature.ps1` when creating new features. + +### 4. Worktree Location Preview + +After selecting worktree options, users see a preview of where features will be created: + +``` +Worktree preview: Features will be created at /Users/user/projects/my-project- +``` + +### 5. Post-Init Worktree Notice + +When worktree mode is enabled, a prominent notice is displayed: + +``` +╭─────────────────────────────── Worktree Mode ────────────────────────────────╮ +│ │ +│ Git Worktree Mode Enabled │ +│ │ +│ When you run /speckit.specify, each feature will be created in its own │ +│ directory alongside this repo (e.g., ../my-project-). │ +│ │ +│ Important: After creating a feature, you must switch your coding agent/IDE │ +│ to the new worktree directory to continue working on that feature. │ +│ │ +│ To change this later, run: .specify/scripts/bash/configure-worktree.sh │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` + +### 6. Agent Notification (specify.md Template) + +The `/speckit.specify` command template now instructs agents to display a prominent warning when worktree mode is used: + +```markdown +## ⚠️ ACTION REQUIRED: Switch to Worktree + +This feature was created in **worktree mode**. Your files are in a separate directory: + +**Worktree Path**: `/Users/user/projects/my-project-001-user-auth` + +**You must switch your coding agent/IDE to this directory** before running any +subsequent commands (`/speckit.clarify`, `/speckit.plan`, `/speckit.implement`, etc.). +``` + +## Safety & Validation Improvements + +### 1. Git Version Check + +Worktree mode requires Git 2.5+. The CLI validates this before allowing worktree mode: + +``` +Error: Cannot use --git-mode worktree: Git 2.4 found, but worktree requires Git 2.5+ +``` + +### 2. Conflict Detection + +The CLI detects conflicting options: + +```bash +$ specify init my-project --no-git --git-mode worktree +Error: Cannot use --git-mode worktree with --no-git (worktrees require git) +``` + +### 3. Git Availability Handling + +- If git isn't installed, worktree prompts are skipped entirely +- If git is available but version < 2.5, a note is shown and branch mode is used + +### 4. Automatic `.gitignore` Update + +When using **nested** worktree strategy, `.worktrees/` is automatically added to `.gitignore`: + +```gitignore +# Git worktrees (nested strategy) +.worktrees/ +``` + +## Files Changed + +### Core Implementation + +| File | Changes | +|------|---------| +| `src/specify_cli/__init__.py` | Added CLI options, interactive selection, config writing, validation, previews, notices | +| `scripts/bash/create-new-feature.sh` | Updated `calculate_worktree_path()` for new naming convention | +| `scripts/powershell/create-new-feature.ps1` | Updated `Get-WorktreePath` for new naming convention | +| `templates/commands/specify.md` | Added worktree notification instructions for agents | + +### Documentation + +| File | Changes | +|------|---------| +| `WORKTREE_DESIGN.md` | Updated naming convention documentation | + +### Supporting Scripts (Existing) + +| File | Purpose | +|------|---------| +| `scripts/bash/configure-worktree.sh` | Change worktree settings after init | +| `scripts/powershell/configure-worktree.ps1` | PowerShell equivalent | + +## New Functions Added + +### Python (`src/specify_cli/__init__.py`) + +```python +def get_git_version() -> Optional[Tuple[int, int, int]]: + """Get the installed git version as a tuple (major, minor, patch).""" + +def check_git_worktree_support() -> Tuple[bool, Optional[str]]: + """Check if git version supports worktrees (requires 2.5+).""" +``` + +### New Constants + +```python +GIT_MODE_CHOICES = { + "branch": "Switch branches in place (traditional)", + "worktree": "Parallel directories per feature" +} + +WORKTREE_STRATEGY_CHOICES = { + "sibling": "Alongside repo (../feature-name)", + "nested": "Inside repo (.worktrees/feature-name)", + "custom": "Custom path (specify location)" +} +``` + +## Testing Instructions + +### Manual Testing + +1. **Test interactive flow**: + ```bash + uv run specify init test-project --ai claude + # Select "worktree" when prompted for git workflow + # Select "sibling" for strategy + # Verify preview shows correct path + ``` + +2. **Test CLI options**: + ```bash + uv run specify init test-project --ai claude --git-mode worktree --worktree-strategy sibling + ``` + +3. **Test validation**: + ```bash + # Should error + uv run specify init test-project --no-git --git-mode worktree + ``` + +4. **Test feature creation**: + ```bash + cd test-project + # Run /speckit.specify in your AI agent + # Verify worktree is created at ../test-project- + ``` + +### Verify Config Written + +After init, check `.specify/config.json`: + +```bash +cat test-project/.specify/config.json +``` + +Expected output: +```json +{ + "git_mode": "worktree", + "worktree_strategy": "sibling", + "worktree_custom_path": "" +} +``` + +## Backward Compatibility + +- **Default behavior unchanged**: Without `--git-mode`, the CLI defaults to `branch` mode +- **Existing projects**: The `configure-worktree.sh` script allows changing settings after init +- **Script compatibility**: `create-new-feature.sh` reads config and falls back gracefully if not present + +## Known Limitations + +1. **Sandbox environments**: Sibling worktrees (`../`) may fail in restricted container environments if the parent directory isn't mounted +2. **IDE/Agent switching**: Users must manually switch their IDE or coding agent to the worktree directory after feature creation + +## Future Considerations + +- Add `specify worktree list` command to show active worktrees +- Add `specify worktree clean` command to prune orphaned worktrees +- Consider auto-detecting IDE and providing copy-paste commands for switching diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1dedb31949..57c2cd787d 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -230,6 +230,17 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} +GIT_MODE_CHOICES = { + "branch": "Switch branches in place (traditional)", + "worktree": "Parallel directories per feature" +} + +WORKTREE_STRATEGY_CHOICES = { + "sibling": "Alongside repo (../feature-name)", + "nested": "Inside repo (.worktrees/feature-name)", + "custom": "Custom path (specify location)" +} + CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" BANNER = """ @@ -516,7 +527,7 @@ def is_git_repo(path: Path = None) -> bool: """Check if the specified path is inside a git repository.""" if path is None: path = Path.cwd() - + if not path.is_dir(): return False @@ -532,6 +543,54 @@ def is_git_repo(path: Path = None) -> bool: except (subprocess.CalledProcessError, FileNotFoundError): return False +def get_git_version() -> Optional[Tuple[int, int, int]]: + """Get the installed git version as a tuple (major, minor, patch). + + Returns: + Tuple of (major, minor, patch) version numbers, or None if git not found. + """ + try: + result = subprocess.run( + ["git", "--version"], + check=True, + capture_output=True, + text=True + ) + # Output is like: "git version 2.39.0" or "git version 2.39.0.windows.1" + version_str = result.stdout.strip() + # Extract version number after "git version " + if version_str.startswith("git version "): + version_part = version_str[12:].split()[0] # Get first part after "git version " + # Handle versions like "2.39.0.windows.1" by taking first 3 parts + parts = version_part.split(".")[:3] + try: + major = int(parts[0]) if len(parts) > 0 else 0 + minor = int(parts[1]) if len(parts) > 1 else 0 + patch = int(parts[2]) if len(parts) > 2 else 0 + return (major, minor, patch) + except ValueError: + return None + return None + except (subprocess.CalledProcessError, FileNotFoundError): + return None + +def check_git_worktree_support() -> Tuple[bool, Optional[str]]: + """Check if git version supports worktrees (requires 2.5+). + + Returns: + Tuple of (supported: bool, message: Optional[str]) + """ + version = get_git_version() + if version is None: + return False, "Git is not installed" + + major, minor, _ = version + if major < 2 or (major == 2 and minor < 5): + version_str = f"{major}.{minor}" + return False, f"Git {version_str} found, but worktree requires Git 2.5+" + + return True, None + def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Optional[str]]: """Initialize a git repository in the specified path. @@ -947,6 +1006,9 @@ def init( project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, amp, shai, q, bob, or qoder "), script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"), + git_mode: str = typer.Option(None, "--git-mode", help="Git workflow mode: branch (traditional) or worktree (parallel directories)"), + worktree_strategy: str = typer.Option(None, "--worktree-strategy", help="Worktree location strategy: sibling, nested, or custom"), + worktree_path: str = typer.Option(None, "--worktree-path", help="Custom worktree base path (required if --worktree-strategy is 'custom')"), ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"), no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"), here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"), @@ -957,15 +1019,16 @@ def init( ): """ Initialize a new Specify project from the latest template. - + This command will: 1. Check that required tools are installed (git is optional) 2. Let you choose your AI assistant - 3. Download the appropriate template from GitHub - 4. Extract the template to a new project directory or current directory - 5. Initialize a fresh git repository (if not --no-git and no existing repo) - 6. Optionally set up AI assistant commands - + 3. Configure git workflow (branch or worktree mode) + 4. Download the appropriate template from GitHub + 5. Extract the template to a new project directory or current directory + 6. Initialize a fresh git repository (if not --no-git and no existing repo) + 7. Save git workflow configuration to .specify/config.json + Examples: specify init my-project specify init my-project --ai claude @@ -978,6 +1041,11 @@ def init( specify init --here --ai codebuddy specify init --here specify init --here --force # Skip confirmation when current directory not empty + + # Git worktree mode examples: + specify init my-project --git-mode worktree --worktree-strategy sibling + specify init my-project --git-mode worktree --worktree-strategy nested + specify init my-project --git-mode worktree --worktree-strategy custom --worktree-path /tmp/worktrees """ show_banner() @@ -1088,8 +1156,117 @@ def init( else: selected_script = default_script + # Git workflow mode selection + selected_git_mode = "branch" # Default + selected_worktree_strategy = "sibling" # Default + selected_worktree_path = "" + + # Check for --no-git + --git-mode worktree conflict + if no_git and git_mode == "worktree": + console.print("[red]Error:[/red] Cannot use --git-mode worktree with --no-git (worktrees require git)") + raise typer.Exit(1) + + # Determine if git workflow selection should be shown + git_available = should_init_git # Already checked above + worktree_supported = False + worktree_error_msg = None + + if git_available: + worktree_supported, worktree_error_msg = check_git_worktree_support() + + if git_mode: + if git_mode not in GIT_MODE_CHOICES: + console.print(f"[red]Error:[/red] Invalid git mode '{git_mode}'. Choose from: {', '.join(GIT_MODE_CHOICES.keys())}") + raise typer.Exit(1) + + # Validate worktree mode requirements + if git_mode == "worktree": + if not git_available: + console.print("[red]Error:[/red] Cannot use --git-mode worktree: Git is not installed") + raise typer.Exit(1) + if not worktree_supported: + console.print(f"[red]Error:[/red] Cannot use --git-mode worktree: {worktree_error_msg}") + raise typer.Exit(1) + + selected_git_mode = git_mode + else: + # Interactive selection if TTY available and git is available + if sys.stdin.isatty() and git_available: + if worktree_supported: + selected_git_mode = select_with_arrows(GIT_MODE_CHOICES, "Choose git workflow", "branch") + else: + # Git available but worktree not supported - skip selection, default to branch + console.print(f"[yellow]Note:[/yellow] {worktree_error_msg}. Using branch mode.") + selected_git_mode = "branch" + elif not git_available: + # Git not available - silently default to branch (config will still be written) + selected_git_mode = "branch" + # else: default to "branch" + + # Worktree strategy selection (only if worktree mode) + if selected_git_mode == "worktree": + if worktree_strategy: + if worktree_strategy not in WORKTREE_STRATEGY_CHOICES: + console.print(f"[red]Error:[/red] Invalid worktree strategy '{worktree_strategy}'. Choose from: {', '.join(WORKTREE_STRATEGY_CHOICES.keys())}") + raise typer.Exit(1) + selected_worktree_strategy = worktree_strategy + else: + # Interactive selection if TTY available + if sys.stdin.isatty(): + selected_worktree_strategy = select_with_arrows(WORKTREE_STRATEGY_CHOICES, "Choose worktree location", "sibling") + # else: default to "sibling" + + # Custom path handling + if selected_worktree_strategy == "custom": + if worktree_path: + # Validate it's an absolute path + if not Path(worktree_path).is_absolute(): + console.print(f"[red]Error:[/red] --worktree-path must be an absolute path (got: {worktree_path})") + raise typer.Exit(1) + # Validate parent exists + parent = Path(worktree_path).parent + if not parent.exists(): + console.print(f"[red]Error:[/red] Parent directory does not exist: {parent}") + raise typer.Exit(1) + selected_worktree_path = worktree_path + else: + # Prompt for custom path + if sys.stdin.isatty(): + console.print() + selected_worktree_path = typer.prompt("Enter custom worktree base path (absolute path)") + if not Path(selected_worktree_path).is_absolute(): + console.print(f"[red]Error:[/red] Path must be absolute (got: {selected_worktree_path})") + raise typer.Exit(1) + parent = Path(selected_worktree_path).parent + if not parent.exists(): + console.print(f"[red]Error:[/red] Parent directory does not exist: {parent}") + raise typer.Exit(1) + else: + console.print("[red]Error:[/red] --worktree-path is required when --worktree-strategy is 'custom'") + raise typer.Exit(1) + + # Show worktree location preview + # Naming convention: - for sibling/custom + repo_name = project_path.name + if selected_worktree_strategy == "sibling": + preview_path = project_path.parent / f"{repo_name}-" + elif selected_worktree_strategy == "nested": + # Nested uses just branch name since it's inside the repo + preview_path = project_path / ".worktrees" / "" + else: # custom + preview_path = Path(selected_worktree_path) / f"{repo_name}-" + + console.print() + console.print(f"[cyan]Worktree preview:[/cyan] Features will be created at [green]{preview_path}[/green]") + console.print(f"[cyan]Selected AI assistant:[/cyan] {selected_ai}") console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") + console.print(f"[cyan]Selected git mode:[/cyan] {selected_git_mode}") + if selected_git_mode == "worktree": + strategy_display = selected_worktree_strategy + if selected_worktree_strategy == "custom" and selected_worktree_path: + strategy_display = f"custom ({selected_worktree_path})" + console.print(f"[cyan]Worktree strategy:[/cyan] {strategy_display}") tracker = StepTracker("Initialize Specify Project") @@ -1101,17 +1278,32 @@ def init( tracker.complete("ai-select", f"{selected_ai}") tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) - for key, label in [ + tracker.add("git-workflow", "Configure git workflow") + if selected_git_mode == "worktree": + strategy_info = selected_worktree_strategy + if selected_worktree_strategy == "custom": + strategy_info = f"custom path" + tracker.complete("git-workflow", f"worktree ({strategy_info})") + else: + tracker.complete("git-workflow", "branch") + tracker_steps = [ ("fetch", "Fetch latest release"), ("download", "Download template"), ("extract", "Extract template"), ("zip-list", "Archive contents"), ("extracted-summary", "Extraction summary"), ("chmod", "Ensure scripts executable"), + ("write-config", "Save git workflow config"), + ] + # Add gitignore step only for nested worktree strategy + if selected_git_mode == "worktree" and selected_worktree_strategy == "nested": + tracker_steps.append(("update-gitignore", "Update .gitignore")) + tracker_steps.extend([ ("cleanup", "Cleanup"), ("git", "Initialize git repository"), ("final", "Finalize") - ]: + ]) + for key, label in tracker_steps: tracker.add(key, label) # Track git error message outside Live context so it persists @@ -1128,6 +1320,66 @@ def init( ensure_executable_scripts(project_path, tracker=tracker) + # Write git workflow configuration to .specify/config.json + tracker.start("write-config") + try: + config_dir = project_path / ".specify" + config_dir.mkdir(parents=True, exist_ok=True) + config_file = config_dir / "config.json" + + # Build config object + config_data = { + "git_mode": selected_git_mode, + "worktree_strategy": selected_worktree_strategy, + "worktree_custom_path": selected_worktree_path + } + + # If config file exists, merge with existing content + if config_file.exists(): + try: + with open(config_file, 'r', encoding='utf-8') as f: + existing_config = json.load(f) + # Update existing config with new git workflow settings + existing_config.update(config_data) + config_data = existing_config + except (json.JSONDecodeError, IOError): + pass # Use new config if existing is invalid + + with open(config_file, 'w', encoding='utf-8') as f: + json.dump(config_data, f, indent=2) + f.write('\n') + + tracker.complete("write-config", ".specify/config.json") + except Exception as e: + tracker.error("write-config", str(e)) + + # Update .gitignore for nested worktree strategy + if selected_git_mode == "worktree" and selected_worktree_strategy == "nested": + tracker.start("update-gitignore") + try: + gitignore_path = project_path / ".gitignore" + worktree_entry = ".worktrees/" + existing_content = "" + + # Read existing content once + if gitignore_path.exists(): + with open(gitignore_path, 'r', encoding='utf-8') as f: + existing_content = f.read() + + # Check if entry already exists (with or without trailing newline) + if worktree_entry in existing_content or ".worktrees" in existing_content: + tracker.complete("update-gitignore", "already present") + else: + # Append .worktrees/ to .gitignore + with open(gitignore_path, 'a', encoding='utf-8') as f: + # Ensure we start on a new line (reuse existing_content) + if existing_content and not existing_content.endswith('\n'): + f.write('\n') + f.write(f"\n# Git worktrees (nested strategy)\n{worktree_entry}\n") + tracker.complete("update-gitignore", "added .worktrees/") + except Exception as e: + tracker.error("update-gitignore", str(e)) + if not no_git: tracker.start("git") if is_git_repo(project_path): @@ -1197,6 +1449,31 @@ def init( console.print() console.print(security_notice) + # Worktree mode notice + if selected_git_mode == "worktree": + proj_name = project_path.name + strategy_desc = { + "sibling": f"alongside this repo (e.g., [cyan]../{proj_name}-[/cyan])", + "nested": f"inside [cyan].worktrees/[/cyan]", + "custom": f"in [cyan]{selected_worktree_path}/{proj_name}-[/cyan]" + } + location_desc = strategy_desc.get(selected_worktree_strategy, "in a separate directory") + + # Use the correct script path based on selected script type + configure_script = ".specify/scripts/powershell/configure-worktree.ps1" if selected_script == "ps" else ".specify/scripts/bash/configure-worktree.sh" + + worktree_notice = Panel( + f"[bold]Git Worktree Mode Enabled[/bold]\n\n" + f"When you run [cyan]/speckit.specify[/cyan], each feature will be created in its own directory {location_desc}.\n\n" + f"[yellow]Important:[/yellow] After creating a feature, you must switch your coding agent/IDE to the new worktree directory to continue working on that feature.\n\n" + f"To change this later, run: [cyan]{configure_script} --show[/cyan]", + title="[cyan]Worktree Mode[/cyan]", + border_style="cyan", + padding=(1, 2) + ) + console.print() + console.print(worktree_notice) + steps_lines = [] if not here: steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]") diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 3c952d683e..9c70c650d2 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -68,7 +68,12 @@ Given that feature description, do this: - If no existing branches/directories found with this short-name, start with number 1 - You must only ever run this script once per feature - The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for - - The JSON output will contain BRANCH_NAME and SPEC_FILE paths + - The JSON output will contain: + - `BRANCH_NAME`: The feature branch name + - `SPEC_FILE`: Absolute path to spec.md + - `FEATURE_ROOT`: **Working directory** - use this as the base for all file operations + - `MODE`: Either "branch" (standard mode) or "worktree" (parallel development mode) + - **When MODE is "worktree"**: The feature is in a separate working directory. Use `FEATURE_ROOT` as your working directory for all subsequent commands and file operations - For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot") 3. Load `templates/spec-template.md` to understand required sections. @@ -195,6 +200,28 @@ Given that feature description, do this: 7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`). + **CRITICAL - Worktree Mode Notification**: If `MODE` is `worktree`, you **MUST** include a prominent warning section at the end of your completion report: + + ```markdown + --- + + ## ⚠️ ACTION REQUIRED: Switch to Worktree + + This feature was created in **worktree mode**. Your files are in a separate directory: + + **Worktree Path**: `[FEATURE_ROOT]` + + **You must switch your coding agent/IDE to this directory** before running any subsequent commands (`/speckit.clarify`, `/speckit.plan`, `/speckit.implement`, etc.). + + ```bash + cd [FEATURE_ROOT] + ``` + + --- + ``` + + Replace `[FEATURE_ROOT]` with the actual path from the script output. This notification is essential because the agent will not automatically change directories and will operate on the wrong files if the user doesn't switch. + **NOTE:** The script creates and checks out the new branch and initializes the spec file before writing. ## General Guidelines