feat: Add git workflow configuration to specify init#1483
feat: Add git workflow configuration to specify init#1483brianluby wants to merge 11 commits intogithub:mainfrom
Conversation
Enable developers to work on multiple features simultaneously using git worktrees instead of switching branches. Features can be created in separate working directories, allowing independent development contexts. Key changes: - Add configure-worktree.sh/ps1 scripts for mode and strategy configuration - Extend create-new-feature scripts with worktree creation and fallback logic - Add read_config_value/Get-ConfigValue functions to common scripts - Extend JSON output with FEATURE_ROOT and MODE fields for AI agent context - Support nested, sibling, and custom worktree placement strategies - Add graceful fallback to branch mode on worktree creation failure - Detect and warn about uncommitted changes and orphaned worktrees Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add .specify/config.json to .gitignore to prevent committing local preferences with absolute paths - Add warning when jq is not available and config file will be overwritten with only worktree settings - Fix brittle JSON parsing in common.sh to support booleans and numbers - Add nullglob handling for empty specs directory in get_highest_from_specs - Improve fallback warning messages to clarify context switch when worktree creation fails and branch mode is used - Sync updated scripts to .specify/scripts/bash/ Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add the ability to configure git worktree mode during project initialization, allowing users to develop multiple features in parallel directories. New CLI options: - --git-mode (branch/worktree) - --worktree-strategy (sibling/nested/custom) - --worktree-path (for custom strategy) Improvements: - Interactive selection for git workflow when options not provided - Git 2.5+ version check for worktree support - Conflict detection for --no-git + --git-mode worktree - Worktree location preview before confirmation - Auto-add .worktrees/ to .gitignore for nested strategy - Prominent agent notification when worktree mode is used Worktree naming convention updated to <repo>-<branch> for sibling and custom strategies for better clarity. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds comprehensive git worktree support to Spec Kit, enabling users to develop multiple features simultaneously in parallel directories instead of switching branches in a single working copy. The implementation allows project initialization with worktree configuration via new CLI options.
Changes:
- Added CLI options for git workflow mode selection (branch vs worktree) with three worktree strategies (sibling, nested, custom)
- Implemented git version checking to ensure Git 2.5+ for worktree support
- Created configuration persistence in
.specify/config.jsonwith merge support - Updated shell scripts (bash and PowerShell) to support worktree creation with automatic fallback to branch mode
- Modified agent instruction templates to notify about worktree directory switching requirements
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/specify_cli/init.py | Added CLI options, git version validation, interactive selection, config writing, and user notifications for worktree workflow |
| templates/commands/specify.md | Added instructions for agents to handle worktree mode and display directory switching warnings |
| scripts/bash/create-new-feature.sh | Implemented worktree path calculation and creation logic with fallback to branch mode |
| scripts/powershell/create-new-feature.ps1 | PowerShell equivalent of bash worktree implementation |
| scripts/bash/configure-worktree.sh | New script for post-init worktree configuration changes |
| scripts/powershell/configure-worktree.ps1 | PowerShell equivalent of configure-worktree script |
| scripts/bash/common.sh | Added read_config_value function for config file parsing |
| scripts/powershell/common.ps1 | Added Get-ConfigValue function for config file parsing |
| .specify/scripts/bash/* | Template copies of bash scripts for distribution |
| WORKTREE_DESIGN.md | Design documentation for worktree feature architecture |
| specs/001-git-worktrees/CHANGELOG.md | Comprehensive feature changelog and usage documentation |
| .gitignore | Added exclusions for config.json and .worktrees/ directory |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/specify_cli/__init__.py
Outdated
| 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].specify/scripts/bash/configure-worktree.sh --show[/cyan]", |
There was a problem hiding this comment.
The worktree notice at line 1470 hardcodes the bash script path ".specify/scripts/bash/configure-worktree.sh", but users who selected PowerShell as their script type (via --script ps or interactive selection) would need to use ".specify/scripts/powershell/configure-worktree.ps1" instead. The notice should use the selected_script variable to show the appropriate script path for the user's chosen script type.
| 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].specify/scripts/bash/configure-worktree.sh --show[/cyan]", | |
| configure_worktree_script_map = { | |
| "bash": ".specify/scripts/bash/configure-worktree.sh", | |
| "ps": ".specify/scripts/powershell/configure-worktree.ps1", | |
| } | |
| configure_worktree_script = configure_worktree_script_map.get( | |
| selected_script, | |
| configure_worktree_script_map["bash"], | |
| ) | |
| 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_worktree_script} --show[/cyan]", |
src/specify_cli/__init__.py
Outdated
| # Ensure we start on a new line | ||
| if gitignore_path.exists(): | ||
| with open(gitignore_path, 'r', encoding='utf-8') as rf: | ||
| content = rf.read() | ||
| if content and not content.endswith('\n'): | ||
| f.write('\n') |
There was a problem hiding this comment.
There's a duplicate file read operation inside the gitignore update block. Lines 1377-1378 open and read the file to check if content ends with a newline, but this same file was just read at lines 1366-1367. The content from the first read should be reused to avoid redundant I/O operations.
|
|
||
| # Git workflow mode selection | ||
| selected_git_mode = "branch" # Default | ||
| selected_worktree_strategy = "sibling" # Default |
There was a problem hiding this comment.
The worktree strategy default value inconsistency could lead to confusion. In the Python CLI at line 1161, the default is set to "sibling", but in multiple other locations (bash/PowerShell scripts at lines with read_config_value calls, and in WORKTREE_DESIGN.md at line 22), the default is "nested". This inconsistency means the behavior will differ depending on whether a user explicitly sets the strategy during init or relies on defaults in the scripts.
| selected_worktree_strategy = "sibling" # Default | |
| selected_worktree_strategy = "nested" # Default |
- Use selected_script to show correct configure-worktree script path (bash vs PowerShell) in worktree mode notice - Remove duplicate file read in gitignore update logic - Standardize default worktree strategy to "sibling" across all files (CLI, bash scripts, PowerShell scripts, design doc) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Previous commit incorrectly replaced all occurrences of "nested" with "sibling". This fix restores the configure-worktree scripts and only updates the default values shown when displaying configuration. "nested" remains a valid strategy option alongside "sibling" and "custom". Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 14 changed files in this pull request and generated 15 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| sibling) | ||
| # Sibling uses repo_name-branch_name for clarity | ||
| echo "$(dirname "$repo_root")/${repo_name}-${branch_name}" |
There was a problem hiding this comment.
The naming convention for custom strategy is inconsistent with the Python init command. The init command uses repo_name-branch_name format (e.g., "/tmp/worktrees/spec-kit-001-user-auth"), but this calculate_worktree_path function uses just branch_name. This will cause worktrees to be created in different locations than what was previewed during init.
| else | ||
| echo "Current configuration ($CONFIG_FILE):" | ||
| echo " git_mode: $(read_config_value "git_mode" "branch")" | ||
| echo " worktree_strategy: $(read_config_value "worktree_strategy" "sibling")" |
There was a problem hiding this comment.
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".
| local strategy | ||
| local custom_path | ||
|
|
||
| strategy=$(read_config_value "worktree_strategy" "nested") |
There was a problem hiding this comment.
The default value for worktree_strategy should be "sibling" to match the constants and init command default, not "nested". This inconsistency could cause unexpected behavior where users select "sibling" during init but the script defaults to "nested".
| if [[ ! -f "$CONFIG_FILE" ]]; then | ||
| echo "No configuration file found. Using defaults:" | ||
| echo " git_mode: branch" | ||
| echo " worktree_strategy: sibling" |
There was a problem hiding this comment.
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".
|
|
||
| strategy=$(read_config_value "worktree_strategy" "nested") | ||
| custom_path=$(read_config_value "worktree_custom_path" "") | ||
|
|
||
| case "$strategy" in | ||
| nested) | ||
| echo "$repo_root/.worktrees/$branch_name" | ||
| ;; | ||
| sibling) | ||
| # Sibling to the repository | ||
| echo "$(dirname "$repo_root")/$branch_name" | ||
| ;; | ||
| custom) | ||
| if [[ -n "$custom_path" ]]; then | ||
| echo "$custom_path/$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" |
There was a problem hiding this comment.
The naming convention for custom strategy is inconsistent with the Python init command. The init command uses repo_name-branch_name format (e.g., "/tmp/worktrees/spec-kit-001-user-auth"), but this calculate_worktree_path function uses just branch_name. This will cause worktrees to be created in different locations than what was previewed during init.
| strategy=$(read_config_value "worktree_strategy" "nested") | |
| custom_path=$(read_config_value "worktree_custom_path" "") | |
| case "$strategy" in | |
| nested) | |
| echo "$repo_root/.worktrees/$branch_name" | |
| ;; | |
| sibling) | |
| # Sibling to the repository | |
| echo "$(dirname "$repo_root")/$branch_name" | |
| ;; | |
| custom) | |
| if [[ -n "$custom_path" ]]; then | |
| echo "$custom_path/$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" | |
| local repo_name | |
| local worktree_dir_name | |
| strategy=$(read_config_value "worktree_strategy" "nested") | |
| custom_path=$(read_config_value "worktree_custom_path" "") | |
| # Use same naming convention as Python init: repo_name-branch_name | |
| repo_name="${repo_root##*/}" | |
| worktree_dir_name="${repo_name}-${branch_name}" | |
| case "$strategy" in | |
| nested) | |
| echo "$repo_root/.worktrees/$worktree_dir_name" | |
| ;; | |
| sibling) | |
| # Sibling to the repository | |
| echo "$(dirname "$repo_root")/$worktree_dir_name" | |
| ;; | |
| custom) | |
| if [[ -n "$custom_path" ]]; then | |
| echo "$custom_path/$worktree_dir_name" | |
| else | |
| # Fallback to nested if custom path not set | |
| echo "$repo_root/.worktrees/$worktree_dir_name" | |
| fi | |
| ;; | |
| *) | |
| # Default to nested | |
| echo "$repo_root/.worktrees/$worktree_dir_name" |
| nested) | ||
| # Nested uses just branch name since it's inside the repo | ||
| echo "$repo_root/.worktrees/$branch_name" |
There was a problem hiding this comment.
The naming convention for sibling strategy is inconsistent with the Python init command. The init command uses repo_name-branch_name format (e.g., "spec-kit-001-user-auth"), but this calculate_worktree_path function uses just branch_name for sibling strategy. This will cause worktrees to be created in different locations than what was previewed during init.
|
|
||
| # Read existing values | ||
| CURRENT_MODE=$(read_config_value "git_mode" "branch") | ||
| CURRENT_STRATEGY=$(read_config_value "worktree_strategy" "sibling") |
There was a problem hiding this comment.
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".
| echo " worktree_strategy: nested" | ||
| 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" "nested")" |
There was a problem hiding this comment.
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".
| echo " worktree_strategy: nested" | |
| 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" "nested")" | |
| 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")" |
|
|
||
| # Read existing values | ||
| CURRENT_MODE=$(read_config_value "git_mode" "branch") | ||
| CURRENT_STRATEGY=$(read_config_value "worktree_strategy" "nested") |
There was a problem hiding this comment.
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".
|
|
||
| echo "Configuration updated:" | ||
| echo " git_mode: $(read_config_value "git_mode" "branch")" | ||
| echo " worktree_strategy: $(read_config_value "worktree_strategy" "nested")" |
There was a problem hiding this comment.
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".
- Update .specify/scripts/bash/configure-worktree.sh with sibling as default strategy (matching scripts/bash/ and Python init command) - Update .specify/scripts/bash/create-new-feature.sh with: - sibling as default strategy - repo_name-branch_name naming convention for sibling/custom strategies - Ensures installed scripts match source templates Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…dling - Gate pre-flight warnings (uncommitted changes, orphaned worktrees) on worktree mode only — branch-mode users no longer see irrelevant warnings - Replace silent worktree-to-branch fallbacks with clear, actionable error messages that guide users toward resolution - Fix jq injection vulnerability in configure-worktree.sh by using --arg for all user-supplied values - Fix PowerShell temp file leak in writability checks with try/finally - Add optional config file path to read_config_value to avoid redundant get_repo_root() subprocess calls - Restore HAS_GIT in PowerShell JSON output and add to bash for cross-platform consistency - Move WORKTREE_DESIGN.md into specs/001-git-worktrees/ - Bump version to 0.0.23 with CHANGELOG entry Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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 <path>" | ||
| >&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 |
There was a problem hiding this comment.
The worktree creation logic has a duplicated conditional check. Lines 360-383 check if [ "$GIT_MODE" = "worktree" ] to set up the parent directory, and then lines 385-414 check the same condition again for the actual worktree creation. These checks should be combined into a single conditional block to improve code clarity and avoid the redundant condition evaluation.
| 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 <path>" | ||
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
Similar to the bash script, the worktree creation logic has a duplicated conditional check. Lines 332-369 check if ($gitMode -eq "worktree") to set up the parent directory, and then lines 371-415 check the same condition again for the actual worktree creation. These checks should be combined into a single conditional block to improve code clarity and avoid the redundant condition evaluation.
CHANGELOG.md
Outdated
| - **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) | ||
| - Automatic fallback to branch mode when worktree creation fails (e.g., unwritable paths) |
There was a problem hiding this comment.
The CHANGELOG states "Automatic fallback to branch mode when worktree creation fails" which is inaccurate. Looking at the code in scripts/bash/create-new-feature.sh lines 385-414 and scripts/powershell/create-new-feature.ps1 lines 371-415, when worktree creation fails, the scripts exit with error code 1 and display error messages. There is no automatic fallback to branch mode. The error handling explicitly calls exit 1 after displaying suggestions.
| - Automatic fallback to branch mode when worktree creation fails (e.g., unwritable paths) | |
| - Clear, actionable error messages and non-zero exit when worktree creation fails (no automatic fallback to branch mode) |
| * 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 |
There was a problem hiding this comment.
The design document describes a rollback plan that mentions "fall back to standard branch creation with a warning" if worktree creation fails. However, the actual implementation in the scripts exits with an error instead of falling back. This is inconsistent with the design document. Either the design document should be updated to reflect the actual behavior (exit on error), or the implementation should include a fallback mechanism as originally designed.
CHANGELOG.md
Outdated
| ## [0.0.23] - 2025-02-04 | ||
|
|
||
| ### 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) | ||
| - Automatic fallback to branch mode when worktree creation fails (e.g., unwritable paths) | ||
| - 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 |
There was a problem hiding this comment.
The changelog dates are not in chronological order. Version 0.0.23 is dated "2025-02-04", which is earlier than version 0.0.22 dated "2025-11-07". Since 0.0.23 is the newer version, it should have a more recent date. Additionally, given that the current date is February 5, 2026, the date "2025-02-04" for version 0.0.23 appears to be a typo and should likely be "2026-02-04" or "2026-02-05".
….specify/ The 3 scripts under .specify/scripts/bash/ were copies of scripts/bash/ and created a maintenance burden (issue M6 from PR review). Untrack them and broaden .gitignore from just .specify/config.json to the entire .specify/ directory, which is generated per-project by specify init. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # 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 | ||
| } |
There was a problem hiding this comment.
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.
| # 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 | |
| } | |
| # Validate that the custom path itself is (or can be created as) a writable directory | |
| if (Test-Path $Path) { | |
| $pathItem = Get-Item $Path -ErrorAction Stop | |
| if (-not $pathItem.PSIsContainer) { | |
| Write-Error "Error: Custom path exists but is not a directory: $Path" | |
| exit 1 | |
| } | |
| # Test writability by attempting to create a temp file in the custom path | |
| $testFile = Join-Path $Path ".specify-write-test-$(Get-Random)" | |
| try { | |
| New-Item -ItemType File -Path $testFile -Force -ErrorAction Stop | Out-Null | |
| } | |
| catch { | |
| Write-Error "Error: Custom path directory is not writable: $Path" | |
| exit 1 | |
| } | |
| finally { | |
| Remove-Item $testFile -Force -ErrorAction SilentlyContinue | |
| } | |
| } | |
| else { | |
| # Attempt to create the custom path directory | |
| try { | |
| New-Item -ItemType Directory -Path $Path -Force -ErrorAction Stop | Out-Null | |
| } | |
| catch { | |
| Write-Error "Error: Cannot create directory for custom path: $Path" | |
| exit 1 | |
| } | |
| } |
| 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" |
There was a problem hiding this comment.
The JSON output uses printf with %s format specifiers without proper escaping. If SPEC_FILE or FEATURE_ROOT contain special characters (like quotes, backslashes, or newlines), this could produce invalid JSON. Consider using a JSON library or implementing proper escaping for string values (e.g., escaping quotes, backslashes, and control characters).
| * **Strategies**: | ||
| * `nested`: Creates worktrees inside `<PROJECT_ROOT>/.worktrees/<branch>`. (Safest for sandboxes). | ||
| * `sibling`: Creates worktrees in `../<project-name>-<branch>`. (User preferred workflow). | ||
| * `custom`: Creates worktrees in `<custom_path>/<branch>`. |
There was a problem hiding this comment.
The documentation states that the custom strategy creates worktrees in <custom_path>/<branch>, but the actual implementation (lines 36-37) uses <custom_path>/<repo_name>-<branch_name>. The documentation should be updated to match the implementation, which uses the repo name prefix for consistency with the sibling strategy.
| * `custom`: Creates worktrees in `<custom_path>/<branch>`. | |
| * `custom`: Creates worktrees in `<custom_path>/<repo_name>-<branch_name>`. |
| # Check if entry already exists (with or without trailing newline) | ||
| if worktree_entry in existing_content or ".worktrees" in existing_content: |
There was a problem hiding this comment.
The condition ".worktrees" in existing_content is too broad and could match unintended strings like "my-worktrees" in comments or other contexts. Consider checking for the exact pattern with word boundaries or line boundaries to avoid false positives.
| # Check if entry already exists (with or without trailing newline) | |
| if worktree_entry in existing_content or ".worktrees" in existing_content: | |
| # Check if entry already exists as a dedicated ignore rule | |
| existing_lines = [line.strip() for line in existing_content.splitlines()] | |
| if any(line == ".worktrees" or line == ".worktrees/" for line in existing_lines): |
| 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) |
There was a problem hiding this comment.
When checking if the parent directory exists, consider that the worktree_path itself might need to be created (not just its parent). The current validation checks if the parent of worktree_path exists, but worktree_path is meant to be a base directory where multiple worktrees will be created (e.g., /tmp/worktrees/myrepo-feature1, /tmp/worktrees/myrepo-feature2). The validation should ensure the base path can be created, not just its parent.
| 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) | |
| base_path = Path(worktree_path) | |
| if not base_path.is_absolute(): | |
| console.print(f"[red]Error:[/red] --worktree-path must be an absolute path (got: {worktree_path})") | |
| raise typer.Exit(1) | |
| # Validate base path is a directory or can be created | |
| if base_path.exists(): | |
| if not base_path.is_dir(): | |
| console.print(f"[red]Error:[/red] --worktree-path exists and is not a directory: {base_path}") | |
| raise typer.Exit(1) | |
| else: | |
| parent = base_path.parent | |
| if not parent.exists(): | |
| console.print(f"[red]Error:[/red] Parent directory does not exist: {parent}") | |
| raise typer.Exit(1) | |
| if not os.access(parent, os.W_OK | os.X_OK): | |
| console.print(f"[red]Error:[/red] Cannot create worktree base directory under: {parent}") | |
| raise typer.Exit(1) | |
| selected_worktree_path = str(base_path) | |
| else: | |
| # Prompt for custom path | |
| if sys.stdin.isatty(): | |
| console.print() | |
| selected_worktree_path = typer.prompt("Enter custom worktree base path (absolute path)") | |
| base_path = Path(selected_worktree_path) | |
| if not base_path.is_absolute(): | |
| console.print(f"[red]Error:[/red] Path must be absolute (got: {selected_worktree_path})") | |
| raise typer.Exit(1) | |
| # Validate base path is a directory or can be created | |
| if base_path.exists(): | |
| if not base_path.is_dir(): | |
| console.print(f"[red]Error:[/red] Path exists and is not a directory: {base_path}") | |
| raise typer.Exit(1) | |
| else: | |
| parent = base_path.parent | |
| if not parent.exists(): | |
| console.print(f"[red]Error:[/red] Parent directory does not exist: {parent}") | |
| raise typer.Exit(1) | |
| if not os.access(parent, os.W_OK | os.X_OK): | |
| console.print(f"[red]Error:[/red] Cannot create worktree base directory under: {parent}") | |
| raise typer.Exit(1) | |
| selected_worktree_path = str(base_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) |
There was a problem hiding this comment.
The same parent directory validation issue exists here. The validation checks if the parent of the user-provided path exists, but the path itself is a base directory that should be validated for writability, not just its parent.
| 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) | |
| base_path = Path(worktree_path) | |
| if not base_path.is_absolute(): | |
| console.print(f"[red]Error:[/red] --worktree-path must be an absolute path (got: {worktree_path})") | |
| raise typer.Exit(1) | |
| # Validate base path exists, is a directory, and is writable | |
| if not base_path.exists(): | |
| console.print(f"[red]Error:[/red] Worktree base directory does not exist: {base_path}") | |
| raise typer.Exit(1) | |
| if not base_path.is_dir(): | |
| console.print(f"[red]Error:[/red] Worktree base path is not a directory: {base_path}") | |
| raise typer.Exit(1) | |
| if not os.access(str(base_path), os.W_OK | os.X_OK): | |
| console.print(f"[red]Error:[/red] Worktree base directory is not writable: {base_path}") | |
| raise typer.Exit(1) | |
| selected_worktree_path = str(base_path) | |
| else: | |
| # Prompt for custom path | |
| if sys.stdin.isatty(): | |
| console.print() | |
| selected_worktree_path = typer.prompt("Enter custom worktree base path (absolute path)") | |
| base_path = Path(selected_worktree_path) | |
| if not base_path.is_absolute(): | |
| console.print(f"[red]Error:[/red] Path must be absolute (got: {selected_worktree_path})") | |
| raise typer.Exit(1) | |
| # Validate base path exists, is a directory, and is writable | |
| if not base_path.exists(): | |
| console.print(f"[red]Error:[/red] Worktree base directory does not exist: {base_path}") | |
| raise typer.Exit(1) | |
| if not base_path.is_dir(): | |
| console.print(f"[red]Error:[/red] Worktree base path is not a directory: {base_path}") | |
| raise typer.Exit(1) | |
| if not os.access(str(base_path), os.W_OK | os.X_OK): | |
| console.print(f"[red]Error:[/red] Worktree base directory is not writable: {base_path}") | |
| raise typer.Exit(1) | |
| selected_worktree_path = str(base_path) |
| # 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 |
There was a problem hiding this comment.
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.
| # 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 |
- Fix CHANGELOG date (2025 → 2026) and remove stale "automatic fallback" line that contradicts the actual error-exit behavior - Escape backslashes and quotes in jq-fallback JSON construction to prevent broken output from paths with special characters - Add -ConfigFile parameter to PowerShell Get-ConfigValue for parity with bash read_config_value, avoiding redundant Get-RepoRoot calls - Fix script path in __init__.py: .specify/scripts/ → scripts/ since .specify/ is now gitignored Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Summary
Add the ability to configure git worktree mode during project initialization via
specify init, allowing users to develop multiple features simultaneously in parallel directories.New CLI Options
--git-modebranch/worktreebranch--worktree-strategysibling/nested/customsibling--worktree-pathcustom)Key Features
--no-git+--git-mode worktree.worktrees/to.gitignorefor nested strategyspecify.mdtemplate)<repo>-<branch>for sibling/custom strategiesUsage Examples
Test Plan
specify initwith--git-mode worktree --worktree-strategy siblingspecify initwith--git-mode worktree --worktree-strategy nested.gitignoreupdated for nested strategy--no-git --git-mode worktree/speckit.specifyand verify worktree created with correct namingAI Disclosure
This PR was developed with assistance from Claude Code (Claude Opus 4.5). Changes were tested and reviewed by the contributor.
🤖 Generated with Claude Code