diff --git a/.gitignore b/.gitignore index 1ed573622d..a9bd6a6627 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,9 @@ env/ .genreleases/ *.zip sdd-*/ + +# Local .specify directory (generated by specify init, contains user-specific config and scripts) +.specify/ + +# Git worktrees (nested strategy) +.worktrees/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e2ac3697f..3b4e263c99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ All notable changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.23] - 2026-02-05 + +### Added + +- **Git Worktree Support**: `create-new-feature` scripts now support worktree-based feature isolation as an alternative to branch switching + - Configure with `configure-worktree.sh` / `configure-worktree.ps1` to choose between `branch` (default) and `worktree` modes + - Three worktree placement strategies: `nested` (inside repo), `sibling` (alongside repo), `custom` (user-specified path) + - Pre-flight warnings for uncommitted changes and orphaned worktrees (worktree mode only) + - JSON output includes `FEATURE_ROOT` and `MODE` fields for automation + +### Changed + +- Worktree configuration stored in `.specify/config.json` (supports `git_mode`, `worktree_strategy`, `worktree_custom_path`) +- Worktree creation failures now exit with clear, actionable error messages instead of silently falling back to branch mode +- `HAS_GIT` field added to bash JSON output and restored in PowerShell JSON output for backward compatibility +- `read_config_value` now accepts an optional config file path parameter to avoid redundant repository root lookups +- Fixed jq injection vulnerability in `configure-worktree.sh` by using `--arg` for user input +- Fixed PowerShell temp file leak in writability checks with proper `try/finally` cleanup +- Moved `WORKTREE_DESIGN.md` into `specs/001-git-worktrees/` to keep design docs with their spec + ## [0.0.22] - 2025-11-07 - Support for VS Code/Copilot agents, and moving away from prompts to proper agents with hand-offs. diff --git a/pyproject.toml b/pyproject.toml index fb972adc7c..61b6a0cc51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.0.22" +version = "0.0.23" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 2c3165e41d..e3e1bbf9c5 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -154,3 +154,46 @@ 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] [config_file_path] +# Returns the value or default if not found +read_config_value() { + local key="$1" + local default_value="${2:-}" + local config_file="${3:-}" + + if [[ -z "$config_file" ]]; then + local repo_root + repo_root=$(get_repo_root) + config_file="$repo_root/.specify/config.json" + fi + + 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..9cd5fda1c4 --- /dev/null +++ b/scripts/bash/configure-worktree.sh @@ -0,0 +1,238 @@ +#!/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 using --arg to prevent injection via user input + JQ_ARGS=() + UPDATE_EXPR="." + + if [[ -n "$MODE" ]]; then + JQ_ARGS+=(--arg mode "$MODE") + UPDATE_EXPR="$UPDATE_EXPR | .git_mode = \$mode" + fi + + if [[ -n "$STRATEGY" ]]; then + JQ_ARGS+=(--arg strategy "$STRATEGY") + UPDATE_EXPR="$UPDATE_EXPR | .worktree_strategy = \$strategy" + fi + + if [[ -n "$CUSTOM_PATH" ]]; then + JQ_ARGS+=(--arg cpath "$CUSTOM_PATH") + UPDATE_EXPR="$UPDATE_EXPR | .worktree_custom_path = \$cpath" + 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 "${JQ_ARGS[@]}" "$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 + + # Escape backslashes and double quotes for JSON safety + CURRENT_PATH="${CURRENT_PATH//\\/\\\\}" + CURRENT_PATH="${CURRENT_PATH//\"/\\\"}" + + # Write JSON manually + printf '{\n "git_mode": "%s",\n "worktree_strategy": "%s",\n "worktree_custom_path": "%s"\n}\n' \ + "$CURRENT_MODE" "$CURRENT_STRATEGY" "$CURRENT_PATH" > "$CONFIG_FILE" +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..3f913ffa80 --- 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,67 @@ 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 config_file="$repo_root/.specify/config.json" + local strategy + local custom_path + local repo_name + + strategy=$(read_config_value "worktree_strategy" "sibling" "$config_file") + custom_path=$(read_config_value "worktree_custom_path" "" "$config_file") + 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 +336,101 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" fi +# Determine git mode and create feature +CONFIG_FILE="$REPO_ROOT/.specify/config.json" +GIT_MODE=$(read_config_value "git_mode" "branch" "$CONFIG_FILE") + +# Worktree-specific pre-flight checks (only in worktree mode) +if [ "$HAS_GIT" = true ] && [ "$GIT_MODE" = "worktree" ]; then + # Check for uncommitted changes (warning only, per FR-013) + 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 + + # Check for orphaned worktrees (warning only, per FR-012) + 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 +CREATION_MODE="branch" +FEATURE_ROOT="$REPO_ROOT" +WORKTREE_PATH="" + if [ "$HAS_GIT" = true ]; then - git checkout -b "$BRANCH_NAME" + 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] Error: Cannot create worktree parent directory: $WORKTREE_PARENT" + >&2 echo "[specify] Suggestions:" + >&2 echo "[specify] - Use nested strategy: configure-worktree.sh --strategy nested" + >&2 echo "[specify] - Switch to branch mode: configure-worktree.sh --mode branch" + >&2 echo "[specify] - Create the directory manually and retry" + exit 1 + } + elif [[ ! -w "$WORKTREE_PARENT" ]]; then + >&2 echo "[specify] Error: Worktree parent directory is not writable: $WORKTREE_PARENT" + >&2 echo "[specify] Suggestions:" + >&2 echo "[specify] - Use nested strategy: configure-worktree.sh --strategy nested" + >&2 echo "[specify] - Switch to branch mode: configure-worktree.sh --mode branch" + >&2 echo "[specify] - Fix directory permissions and retry" + exit 1 + 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 + >&2 echo "[specify] Error: Failed to create worktree for existing branch '$BRANCH_NAME' at $WORKTREE_PATH" + >&2 echo "[specify] Suggestions:" + >&2 echo "[specify] - Check existing worktrees: git worktree list" + >&2 echo "[specify] - Remove stale worktree: git worktree remove " + >&2 echo "[specify] - Prune orphaned entries: git worktree prune" + >&2 echo "[specify] - Switch to branch mode: configure-worktree.sh --mode branch" + exit 1 + 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 + >&2 echo "[specify] Error: Failed to create worktree for new branch '$BRANCH_NAME' at $WORKTREE_PATH" + >&2 echo "[specify] Suggestions:" + >&2 echo "[specify] - Check existing worktrees: git worktree list" + >&2 echo "[specify] - Prune orphaned entries: git worktree prune" + >&2 echo "[specify] - Switch to branch mode: configure-worktree.sh --mode branch" + exit 1 + 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 +441,14 @@ 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","HAS_GIT":%s}\n' \ + "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM" "$FEATURE_ROOT" "$CREATION_MODE" "$HAS_GIT" 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 "HAS_GIT: $HAS_GIT" echo "SPECIFY_FEATURE environment variable set to: $BRANCH_NAME" fi diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index b0be273545..f6efebdb11 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -135,3 +135,39 @@ function Test-DirHasFiles { } } +# Read a value from .specify/config.json +# Usage: Get-ConfigValue -Key "git_mode" [-Default "branch"] [-ConfigFile "path"] +# Returns the value or default if not found +function Get-ConfigValue { + param( + [Parameter(Mandatory = $true)] + [string]$Key, + [string]$Default = "", + [string]$ConfigFile = "" + ) + + if (-not $ConfigFile) { + $repoRoot = Get-RepoRoot + $ConfigFile = Join-Path $repoRoot ".specify/config.json" + } + $configFile = $ConfigFile + + 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..423cec0e79 --- /dev/null +++ b/scripts/powershell/configure-worktree.ps1 @@ -0,0 +1,175 @@ +#!/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 + $testFile = Join-Path $parentPath ".specify-write-test-$(Get-Random)" + try { + New-Item -ItemType File -Path $testFile -Force -ErrorAction Stop | Out-Null + } + catch { + Write-Error "Error: Parent directory is not writable: $parentPath" + exit 1 + } + finally { + Remove-Item $testFile -Force -ErrorAction SilentlyContinue + } +} + +# 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..77af48d2a3 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,73 @@ 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 + ) + + $cfgFile = Join-Path $RepoRoot ".specify/config.json" + $strategy = Get-ConfigValue -Key "worktree_strategy" -Default "sibling" -ConfigFile $cfgFile + $customPath = Get-ConfigValue -Key "worktree_custom_path" -Default "" -ConfigFile $cfgFile + $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,42 +308,171 @@ if ($branchName.Length -gt $maxBranchLength) { Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" } +# Determine git mode and create feature +$configFile = Join-Path $repoRoot ".specify/config.json" +$gitMode = Get-ConfigValue -Key "git_mode" -Default "branch" -ConfigFile $configFile + +# Worktree-specific pre-flight checks (only in worktree mode) +if ($hasGit -and $gitMode -eq "worktree") { + # Check for uncommitted changes (warning only, per FR-013) + $status = git status --porcelain 2>$null + if ($status) { + Write-Warning "[specify] Warning: Uncommitted changes in working directory will not appear in new worktree." + } + + # Check for orphaned worktrees (warning only, per FR-012) + $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." + } +} +$creationMode = "branch" +$featureRoot = $repoRoot +$worktreePath = "" + if ($hasGit) { - try { - git checkout -b $branchName | Out-Null - } catch { - Write-Warning "Failed to create git branch: $branchName" + 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-Error "[specify] Error: Cannot create worktree parent directory: $worktreeParent" + Write-Error "[specify] Suggestions:" + Write-Error "[specify] - Use nested strategy: configure-worktree.ps1 -Strategy nested" + Write-Error "[specify] - Switch to branch mode: configure-worktree.ps1 -Mode branch" + Write-Error "[specify] - Create the directory manually and retry" + exit 1 + } + } + else { + # Test writability by attempting to create a temp file + $testFile = Join-Path $worktreeParent ".specify-write-test-$(Get-Random)" + try { + New-Item -ItemType File -Path $testFile -Force -ErrorAction Stop | Out-Null + } + catch { + Write-Error "[specify] Error: Worktree parent directory is not writable: $worktreeParent" + Write-Error "[specify] Suggestions:" + Write-Error "[specify] - Use nested strategy: configure-worktree.ps1 -Strategy nested" + Write-Error "[specify] - Switch to branch mode: configure-worktree.ps1 -Mode branch" + Write-Error "[specify] - Fix directory permissions and retry" + exit 1 + } + finally { + Remove-Item $testFile -Force -ErrorAction SilentlyContinue + } + } } -} else { + + 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 { + Write-Error "[specify] Error: Failed to create worktree for existing branch '$branchName' at $worktreePath" + Write-Error "[specify] Suggestions:" + Write-Error "[specify] - Check existing worktrees: git worktree list" + Write-Error "[specify] - Remove stale worktree: git worktree remove " + Write-Error "[specify] - Prune orphaned entries: git worktree prune" + Write-Error "[specify] - Switch to branch mode: configure-worktree.ps1 -Mode branch" + exit 1 + } + } + 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 { + Write-Error "[specify] Error: Failed to create worktree for new branch '$branchName' at $worktreePath" + Write-Error "[specify] Suggestions:" + Write-Error "[specify] - Check existing worktrees: git worktree list" + Write-Error "[specify] - Prune orphaned entries: git worktree prune" + Write-Error "[specify] - Switch to branch mode: configure-worktree.ps1 -Mode branch" + exit 1 + } + } + } + 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 + HAS_GIT = $hasGit } $obj | ConvertTo-Json -Compress -} else { +} +else { Write-Output "BRANCH_NAME: $branchName" Write-Output "SPEC_FILE: $specFile" Write-Output "FEATURE_NUM: $featureNum" + Write-Output "FEATURE_ROOT: $featureRoot" + Write-Output "MODE: $creationMode" Write-Output "HAS_GIT: $hasGit" 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/specs/001-git-worktrees/WORKTREE_DESIGN.md b/specs/001-git-worktrees/WORKTREE_DESIGN.md new file mode 100644 index 0000000000..8fdb03d206 --- /dev/null +++ b/specs/001-git-worktrees/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/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1dedb31949..aeeb41f529 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 = "scripts/powershell/configure-worktree.ps1" if selected_script == "ps" else "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