Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
43 changes: 43 additions & 0 deletions scripts/bash/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

238 changes: 238 additions & 0 deletions scripts/bash/configure-worktree.sh
Original file line number Diff line number Diff line change
@@ -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 <branch|worktree> Set git mode (default: branch)
--strategy <nested|sibling|custom> Set worktree placement strategy
--path <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"
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default value for worktree_strategy in the scripts is "nested", but the init command and documentation state the default is "sibling". This creates an inconsistency where the --show command will display "nested" as default instead of "sibling".

Copilot uses AI. Check for mistakes.
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")"
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default value for worktree_strategy in the scripts is "nested", but the init command and documentation state the default is "sibling". This creates an inconsistency where the --show command will display "nested" as default instead of "sibling".

Copilot uses AI. Check for mistakes.
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
Comment on lines +149 to +158
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation checks if the parent of the custom path exists and is writable, but the custom path itself is meant to be a base directory where worktrees will be created. The validation should check if the custom path can be created (if it doesn't exist) or is writable (if it exists), rather than checking its parent.

Suggested change
# 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
# Validate the custom path itself (if it exists) or its parent (if it does not)
if [[ -e "$CUSTOM_PATH" ]]; then
if [[ ! -d "$CUSTOM_PATH" ]]; then
echo "Error: Custom path exists but is not a directory: $CUSTOM_PATH" >&2
exit 1
fi
if [[ ! -w "$CUSTOM_PATH" ]]; then
echo "Error: Custom path directory is not writable: $CUSTOM_PATH" >&2
exit 1
fi
else
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

Copilot uses AI. Check for mistakes.
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")
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default value for worktree_strategy in the scripts is "nested", but the init command and documentation state the default is "sibling". This creates an inconsistency where the --show command will display "nested" as default instead of "sibling".

Copilot uses AI. Check for mistakes.
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")"
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default value for worktree_strategy in the scripts is "nested", but the init command and documentation state the default is "sibling". This creates an inconsistency where the --show command will display "nested" as default instead of "sibling".

Copilot uses AI. Check for mistakes.
custom_path=$(read_config_value "worktree_custom_path" "")
if [[ -n "$custom_path" ]]; then
echo " worktree_custom_path: $custom_path"
fi
Loading