mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
feat: make git extension opt-in and remove --no-git at v0.10.0 (#2873)
* feat(init)!: make git extension opt-in and remove --no-git at v0.10.0 - Remove --no-git parameter from specify init command - Remove git extension auto-installation from init flow - Git repository initialization (git init) still runs when git is available - Remove --no-git from all test invocations across the test suite - Update docs to reflect opt-in git extension behavior - Replace TestGitExtensionAutoInstall with TestGitExtensionOptIn tests BREAKING CHANGE: specify init no longer auto-installs the git extension. Use `specify extension add git` to install it explicitly. The --no-git flag has been removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(scripts): remove git operations from core scripts Git functionality is now entirely managed by the git extension. Core scripts only handle directory-based feature creation and numbering. - Remove has_git(), check_feature_branch(), git branch creation from core - Simplify number detection to use only spec directory scanning - Remove HAS_GIT output from get_feature_paths() - Remove git remote fetching and branch querying - Keep BRANCH_NAME output key for backward compatibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: remove all git operations from core - Remove is_git_repo() and init_git_repo() dead code from _utils.py - Remove --branch-numbering from init command - Remove git from 'specify check' (now extension-only) - Update docs: git is optional prerequisite, check command description - Fix tests to reflect no-git-in-core reality (fallback to main) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(scripts): remove directory scanning and branch fallback from core Core scripts now resolve feature context exclusively from: 1. SPECIFY_FEATURE env var (set by git extension) 2. .specify/feature.json (persisted by specify command) Removed find_feature_dir_by_prefix() and directory scanning heuristics — these are the git extension's responsibility. Scripts error clearly when no feature context is available. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: introduce feature_numbering, deprecate branch_numbering in init-options - specify command template now reads feature_numbering (preferred) with fallback to branch_numbering (deprecated) from init-options.json - Git extension reads git-config.yml > feature_numbering > branch_numbering - init now writes feature_numbering: sequential to init-options.json - Deprecation warning emitted when branch_numbering is used as fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove trailing whitespace in common.ps1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(scripts): persist SPECIFY_FEATURE_DIRECTORY env var to feature.json When SPECIFY_FEATURE_DIRECTORY is set, get_feature_paths() now writes the value to .specify/feature.json so future sessions without the env var can still resolve the feature directory. The write is idempotent — it skips when the file already contains the same value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address review feedback — error messages and docs - Update error messages in common.sh and common.ps1 to reference SPECIFY_FEATURE_DIRECTORY instead of SPECIFY_FEATURE (which no longer resolves feature directories) - Fix get_current_branch comment (returns empty string, not error) - Update upgrade.md to reference SPECIFY_FEATURE_DIRECTORY with correct example paths - Update local-development.md troubleshooting: replace stale 'Git step skipped' row with actionable git extension guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): harden feature.json persistence - Use json_escape in printf fallback when jq is unavailable (common.sh) - Replace utf8NoBOM encoding with UTF8Encoding($false) for PowerShell 5.1 compatibility (common.ps1) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(scripts): remove dead feature_json_matches_feature_dir functions These guards are no longer needed since the branch-name validation they protected against has been removed from check-prerequisites. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(git-ext): rename create-new-feature to create-new-feature-branch The git extension's script only creates the git branch — rename it to reflect that responsibility. The core create-new-feature.sh/.ps1 handles feature directory creation and feature.json persistence. Also includes fixes from review feedback: - common.sh: _persist_feature_json uses json_escape fallback - common.ps1: Save-FeatureJson uses UTF8Encoding for PS 5.1 compat - common.ps1: case-sensitive path stripping on non-Windows - create-new-feature.sh/ps1: output both SPECIFY_FEATURE and SPECIFY_FEATURE_DIRECTORY - setup-tasks.sh: fix stale 'Validate branch' comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): update references to renamed git extension scripts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): remove duplicate EXT_CREATE_FEATURE assignments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
|
||||
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation
|
||||
- [Python 3.11+](https://www.python.org/downloads/)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- [Git](https://git-scm.com/downloads) _(optional — required only when the git extension is enabled)_
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ rm -rf .venv dist build *.egg-info
|
||||
|---------|-----|
|
||||
| `ModuleNotFoundError: typer` | Run `uv pip install -e .` |
|
||||
| Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` |
|
||||
| Git step skipped | You passed `--no-git` or Git not installed |
|
||||
| Git commands unavailable | Install the git extension with `specify extension add git` |
|
||||
| Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly |
|
||||
| TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. |
|
||||
|
||||
|
||||
@@ -15,16 +15,13 @@ specify init [<project_name>]
|
||||
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||
| `--here` | Initialize in the current directory instead of creating a new one |
|
||||
| `--force` | Force merge/overwrite when initializing in an existing directory |
|
||||
| `--no-git` | Skip git repository initialization |
|
||||
| `--ignore-agent-tools` | Skip checks for AI coding agent CLI tools |
|
||||
| `--preset <id>` | Install a preset during initialization |
|
||||
| `--branch-numbering` | Branch numbering strategy: `sequential` (default) or `timestamp` |
|
||||
|
||||
Creates a new Spec Kit project with the necessary directory structure, templates, scripts, and AI coding agent integration files.
|
||||
|
||||
> [!NOTE]
|
||||
> The git extension is currently enabled by default during `specify init`.
|
||||
> Starting in `v0.10.0`, it will require explicit opt-in. To add it after init, run `specify extension add git`.
|
||||
> Git repository initialization and branching are managed by the **git extension**, which is not installed by default. Run `specify extension add git` after init to enable git workflows.
|
||||
|
||||
Use `<project_name>` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation.
|
||||
|
||||
@@ -45,14 +42,8 @@ specify init --here --force --integration copilot
|
||||
# Use PowerShell scripts (Windows/cross-platform)
|
||||
specify init my-project --integration copilot --script ps
|
||||
|
||||
# Skip git initialization
|
||||
specify init my-project --integration copilot --no-git
|
||||
|
||||
# Install a preset during initialization
|
||||
specify init my-project --integration copilot --preset compliance
|
||||
|
||||
# Use timestamp-based branch numbering (useful for distributed teams)
|
||||
specify init my-project --integration copilot --branch-numbering timestamp
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
@@ -67,7 +58,7 @@ specify init my-project --integration copilot --branch-numbering timestamp
|
||||
specify check
|
||||
```
|
||||
|
||||
Checks that required tools are available on your system: `git` and any CLI-based AI coding agents. IDE-based agents are skipped since they don't require a CLI tool.
|
||||
Checks that CLI-based AI coding agents are available on your system. IDE-based agents are skipped since they don't require a CLI tool.
|
||||
|
||||
This command stays offline. If a command behaves like an older Spec Kit version or an expected CLI feature is missing, run `specify self check` to check whether your local CLI is behind the latest release.
|
||||
|
||||
|
||||
@@ -257,70 +257,38 @@ rm speckit.old-command-name.md
|
||||
# Restart your IDE
|
||||
```
|
||||
|
||||
### Scenario 4: "I'm working on a project without Git"
|
||||
### Scenario 4: "I don't want the git extension"
|
||||
|
||||
If you initialized your project with `--no-git`, you can still upgrade:
|
||||
The git extension is now opt-in, so upgrades do not install it unless you add it explicitly.
|
||||
|
||||
```bash
|
||||
# Manually back up files you customized
|
||||
cp .specify/memory/constitution.md /tmp/constitution-backup.md
|
||||
cp .specify/memory/constitution.md .specify/memory/constitution.backup.md
|
||||
|
||||
# Run upgrade
|
||||
specify init --here --force --integration copilot --no-git
|
||||
specify init --here --force --integration copilot
|
||||
|
||||
# Restore customizations
|
||||
mv /tmp/constitution-backup.md .specify/memory/constitution.md
|
||||
mv .specify/memory/constitution.backup.md .specify/memory/constitution.md
|
||||
```
|
||||
|
||||
The `--no-git` flag skips git initialization but doesn't affect file updates.
|
||||
|
||||
---
|
||||
|
||||
## Using `--no-git` Flag
|
||||
|
||||
The `--no-git` flag tells Spec Kit to **skip git repository initialization**. This is useful when:
|
||||
|
||||
- You manage version control differently (Mercurial, SVN, etc.)
|
||||
- Your project is part of a larger monorepo with existing git setup
|
||||
- You're experimenting and don't want version control yet
|
||||
|
||||
**During initial setup:**
|
||||
If you later decide you want the git extension's commands and hooks, install it explicitly:
|
||||
|
||||
```bash
|
||||
specify init my-project --integration copilot --no-git
|
||||
specify extension add git
|
||||
```
|
||||
|
||||
**During upgrade:**
|
||||
|
||||
```bash
|
||||
specify init --here --force --integration copilot --no-git
|
||||
```
|
||||
|
||||
### What `--no-git` does NOT do
|
||||
|
||||
❌ Does NOT prevent file updates
|
||||
❌ Does NOT skip slash command installation
|
||||
❌ Does NOT affect template merging
|
||||
|
||||
It **only** skips running `git init` and creating the initial commit.
|
||||
|
||||
### Working without Git
|
||||
|
||||
If you use `--no-git`, you'll need to manage feature directories manually:
|
||||
|
||||
**Set the `SPECIFY_FEATURE` environment variable** before using planning commands:
|
||||
Projects that do not use Git can still work with Spec Kit by setting `SPECIFY_FEATURE_DIRECTORY` to the feature directory path before planning commands:
|
||||
|
||||
```bash
|
||||
# Bash/Zsh
|
||||
export SPECIFY_FEATURE="001-my-feature"
|
||||
export SPECIFY_FEATURE_DIRECTORY="specs/001-my-feature"
|
||||
|
||||
# PowerShell
|
||||
$env:SPECIFY_FEATURE = "001-my-feature"
|
||||
$env:SPECIFY_FEATURE_DIRECTORY = "specs/001-my-feature"
|
||||
```
|
||||
|
||||
This tells Spec Kit which feature directory to use when creating specs, plans, and tasks.
|
||||
|
||||
**Why this matters:** Without git, Spec Kit can't detect your current branch name to determine the active feature. The environment variable provides that context manually.
|
||||
Alternatively, run the `/speckit.specify` command which creates `.specify/feature.json` automatically.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ When Git is not installed or the directory is not a Git repository:
|
||||
|
||||
The extension bundles cross-platform scripts:
|
||||
|
||||
- `scripts/bash/create-new-feature.sh` — Bash implementation
|
||||
- `scripts/bash/create-new-feature-branch.sh` — Bash implementation (branch creation only)
|
||||
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
|
||||
- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation
|
||||
- `scripts/powershell/create-new-feature-branch.ps1` — PowerShell implementation (branch creation only)
|
||||
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)
|
||||
|
||||
@@ -31,8 +31,9 @@ If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variabl
|
||||
Determine the branch numbering strategy by checking configuration in this order:
|
||||
|
||||
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
||||
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
||||
3. Default to `sequential` if neither exists
|
||||
2. Check `.specify/init-options.json` for `feature_numbering` value (inherit from core)
|
||||
3. Check `.specify/init-options.json` for `branch_numbering` value (deprecated, backward compatibility — will be removed in a future release)
|
||||
4. Default to `sequential` if none of the above exist
|
||||
|
||||
## Execution
|
||||
|
||||
@@ -43,10 +44,10 @@ Generate a concise short name (2-4 words) for the branch:
|
||||
|
||||
Run the appropriate script based on your platform:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature-branch.sh --json --short-name "<short-name>" "<feature description>"`
|
||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature-branch.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature-branch.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature-branch.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
||||
|
||||
**IMPORTANT**:
|
||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Git extension: create-new-feature.sh
|
||||
# Adapted from core scripts/bash/create-new-feature.sh for extension layout.
|
||||
# Git extension: create-new-feature-branch.sh
|
||||
# Creates a git feature branch only. The feature directory and spec file
|
||||
# are created by the core create-new-feature.sh script.
|
||||
# Sources common.sh from the project's installed scripts, falling back to
|
||||
# git-common.sh for minimal git helpers.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Git extension: create-new-feature.ps1
|
||||
# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout.
|
||||
# Git extension: create-new-feature-branch.ps1
|
||||
# Creates a git feature branch only. The feature directory and spec file
|
||||
# are created by the core create-new-feature.ps1 script.
|
||||
# Sources common.ps1 from the project's installed scripts, falling back to
|
||||
# git-common.ps1 for minimal git helpers.
|
||||
[CmdletBinding()]
|
||||
@@ -19,7 +20,7 @@ param(
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
Write-Host "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
Write-Host ""
|
||||
Write-Host "Options:"
|
||||
Write-Host " -Json Output in JSON format"
|
||||
@@ -37,7 +38,7 @@ if ($Help) {
|
||||
}
|
||||
|
||||
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
||||
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
Write-Error "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -111,9 +111,6 @@ if $PATHS_ONLY; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Validate branch name
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
|
||||
# Validate required directories and files
|
||||
if [[ ! -d "$FEATURE_DIR" ]]; then
|
||||
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
|
||||
|
||||
@@ -24,8 +24,8 @@ find_specify_root() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Get repository root, prioritizing .specify directory over git
|
||||
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
|
||||
# Get repository root, prioritizing .specify directory
|
||||
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
|
||||
get_repo_root() {
|
||||
# First, look for .specify directory (spec-kit's own marker)
|
||||
local specify_root
|
||||
@@ -34,123 +34,24 @@ get_repo_root() {
|
||||
return
|
||||
fi
|
||||
|
||||
# Fallback to git if no .specify found
|
||||
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
git rev-parse --show-toplevel
|
||||
return
|
||||
fi
|
||||
|
||||
# Final fallback to script location for non-git repos
|
||||
# Final fallback to script location
|
||||
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
(cd "$script_dir/../../.." && pwd)
|
||||
}
|
||||
|
||||
# Get current branch, with fallback for non-git repositories
|
||||
# Get current feature name from explicit state only.
|
||||
# Returns the feature identifier or empty string if none is set.
|
||||
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
|
||||
# the git extension) or implicitly via .specify/feature.json.
|
||||
get_current_branch() {
|
||||
# First check if SPECIFY_FEATURE environment variable is set
|
||||
if [[ -n "${SPECIFY_FEATURE:-}" ]]; then
|
||||
echo "$SPECIFY_FEATURE"
|
||||
return
|
||||
fi
|
||||
|
||||
# Then check git if available at the spec-kit root (not parent)
|
||||
local repo_root=$(get_repo_root)
|
||||
if has_git; then
|
||||
git -C "$repo_root" rev-parse --abbrev-ref HEAD
|
||||
return
|
||||
fi
|
||||
|
||||
# For non-git repos, try to find the latest feature directory
|
||||
local specs_dir="$repo_root/specs"
|
||||
|
||||
if [[ -d "$specs_dir" ]]; then
|
||||
local latest_feature=""
|
||||
local highest=0
|
||||
local latest_timestamp=""
|
||||
|
||||
for dir in "$specs_dir"/*; do
|
||||
if [[ -d "$dir" ]]; then
|
||||
local dirname=$(basename "$dir")
|
||||
if [[ "$dirname" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
|
||||
# Timestamp-based branch: compare lexicographically
|
||||
local ts="${BASH_REMATCH[1]}"
|
||||
if [[ "$ts" > "$latest_timestamp" ]]; then
|
||||
latest_timestamp="$ts"
|
||||
latest_feature=$dirname
|
||||
fi
|
||||
elif [[ "$dirname" =~ ^([0-9]{3,})- ]]; then
|
||||
local number=${BASH_REMATCH[1]}
|
||||
number=$((10#$number))
|
||||
if [[ "$number" -gt "$highest" ]]; then
|
||||
highest=$number
|
||||
# Only update if no timestamp branch found yet
|
||||
if [[ -z "$latest_timestamp" ]]; then
|
||||
latest_feature=$dirname
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "$latest_feature" ]]; then
|
||||
echo "$latest_feature"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "main" # Final fallback
|
||||
}
|
||||
|
||||
# Check if we have git available at the spec-kit root level
|
||||
# Returns true only if git is installed and the repo root is inside a git work tree
|
||||
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
|
||||
has_git() {
|
||||
# First check if git command is available (before calling get_repo_root which may use git)
|
||||
command -v git >/dev/null 2>&1 || return 1
|
||||
local repo_root=$(get_repo_root)
|
||||
# Check if .git exists (directory or file for worktrees/submodules)
|
||||
[ -e "$repo_root/.git" ] || return 1
|
||||
# Verify it's actually a valid git work tree
|
||||
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
|
||||
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
|
||||
spec_kit_effective_branch_name() {
|
||||
local raw="$1"
|
||||
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
|
||||
printf '%s\n' "${BASH_REMATCH[2]}"
|
||||
else
|
||||
printf '%s\n' "$raw"
|
||||
fi
|
||||
}
|
||||
|
||||
check_feature_branch() {
|
||||
local raw="$1"
|
||||
local has_git_repo="$2"
|
||||
|
||||
# For non-git repos, we can't enforce branch naming but still provide output
|
||||
if [[ "$has_git_repo" != "true" ]]; then
|
||||
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
local branch
|
||||
branch=$(spec_kit_effective_branch_name "$raw")
|
||||
|
||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||
local is_sequential=false
|
||||
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
|
||||
is_sequential=true
|
||||
fi
|
||||
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
|
||||
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
# No explicit feature set — caller must handle this via feature.json
|
||||
# in get_feature_paths(). Return empty to signal "unknown".
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Safely read .specify/feature.json's "feature_directory" value.
|
||||
@@ -185,105 +86,66 @@ read_feature_json_feature_directory() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Returns 0 when .specify/feature.json lists feature_directory that exists as a directory
|
||||
# and matches the resolved active FEATURE_DIR (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks).
|
||||
# Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`.
|
||||
feature_json_matches_feature_dir() {
|
||||
# Persist a feature_directory value to .specify/feature.json.
|
||||
# Writes only when the file is missing or the value differs from what's stored.
|
||||
# Accepts the raw (possibly relative) path — callers should pass the original
|
||||
# user-supplied value, not the normalized absolute path.
|
||||
_persist_feature_json() {
|
||||
local repo_root="$1"
|
||||
local active_feature_dir="$2"
|
||||
local feature_dir_value="$2"
|
||||
local fj="$repo_root/.specify/feature.json"
|
||||
|
||||
local _fd
|
||||
_fd=$(read_feature_json_feature_directory "$repo_root")
|
||||
|
||||
[[ -n "$_fd" ]] || return 1
|
||||
[[ "$_fd" != /* ]] && _fd="$repo_root/$_fd"
|
||||
[[ -d "$_fd" ]] || return 1
|
||||
|
||||
local norm_json norm_active
|
||||
norm_json="$(cd -- "$_fd" 2>/dev/null && pwd -P)" || return 1
|
||||
norm_active="$(cd -- "$active_feature_dir" 2>/dev/null && pwd -P)" || return 1
|
||||
|
||||
[[ "$norm_json" == "$norm_active" ]]
|
||||
}
|
||||
|
||||
# Find feature directory by numeric prefix instead of exact branch match
|
||||
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
|
||||
find_feature_dir_by_prefix() {
|
||||
local repo_root="$1"
|
||||
local branch_name
|
||||
branch_name=$(spec_kit_effective_branch_name "$2")
|
||||
local specs_dir="$repo_root/specs"
|
||||
|
||||
# Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches)
|
||||
local prefix=""
|
||||
if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
|
||||
prefix="${BASH_REMATCH[1]}"
|
||||
elif [[ "$branch_name" =~ ^([0-9]{3,})- ]]; then
|
||||
prefix="${BASH_REMATCH[1]}"
|
||||
else
|
||||
# If branch doesn't have a recognized prefix, fall back to exact match
|
||||
echo "$specs_dir/$branch_name"
|
||||
return
|
||||
# Strip repo_root prefix if the value is absolute and under repo_root
|
||||
if [[ "$feature_dir_value" == "$repo_root/"* ]]; then
|
||||
feature_dir_value="${feature_dir_value#"$repo_root/"}"
|
||||
fi
|
||||
|
||||
# Search for directories in specs/ that start with this prefix
|
||||
local matches=()
|
||||
if [[ -d "$specs_dir" ]]; then
|
||||
for dir in "$specs_dir"/"$prefix"-*; do
|
||||
if [[ -d "$dir" ]]; then
|
||||
matches+=("$(basename "$dir")")
|
||||
fi
|
||||
done
|
||||
# Read current value (if any) and skip write when unchanged
|
||||
local current_val
|
||||
current_val=$(read_feature_json_feature_directory "$repo_root")
|
||||
if [[ "$current_val" == "$feature_dir_value" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Handle results
|
||||
if [[ ${#matches[@]} -eq 0 ]]; then
|
||||
# No match found - return the branch name path (will fail later with clear error)
|
||||
echo "$specs_dir/$branch_name"
|
||||
elif [[ ${#matches[@]} -eq 1 ]]; then
|
||||
# Exactly one match - perfect!
|
||||
echo "$specs_dir/${matches[0]}"
|
||||
# Ensure .specify/ directory exists
|
||||
mkdir -p "$repo_root/.specify"
|
||||
|
||||
# Write feature.json — prefer jq for safe JSON, fall back to printf
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq -cn --arg fd "$feature_dir_value" '{feature_directory:$fd}' > "$fj"
|
||||
else
|
||||
# Multiple matches - this shouldn't happen with proper naming convention
|
||||
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
|
||||
echo "Please ensure only one spec directory exists per prefix." >&2
|
||||
return 1
|
||||
printf '{"feature_directory":"%s"}\n' "$(json_escape "$feature_dir_value")" > "$fj"
|
||||
fi
|
||||
}
|
||||
|
||||
get_feature_paths() {
|
||||
local repo_root=$(get_repo_root)
|
||||
local current_branch=$(get_current_branch)
|
||||
local has_git_repo="false"
|
||||
|
||||
if has_git; then
|
||||
has_git_repo="true"
|
||||
fi
|
||||
|
||||
# Resolve feature directory. Priority:
|
||||
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__)
|
||||
# 3. Branch-name-based prefix lookup (legacy fallback)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by specify command)
|
||||
# 3. Error — no feature context available
|
||||
local feature_dir
|
||||
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
|
||||
feature_dir="$SPECIFY_FEATURE_DIRECTORY"
|
||||
# Normalize relative paths to absolute under repo root
|
||||
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
||||
# Persist to feature.json so future sessions without the env var still work
|
||||
_persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY"
|
||||
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
|
||||
# Shared, set -e-safe parser: jq -> python3 -> grep/sed. Returns empty on
|
||||
# missing/unparseable/unset so we fall through to the branch-prefix lookup.
|
||||
local _fd
|
||||
_fd=$(read_feature_json_feature_directory "$repo_root")
|
||||
if [[ -n "$_fd" ]]; then
|
||||
feature_dir="$_fd"
|
||||
# Normalize relative paths to absolute under repo root
|
||||
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
||||
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
|
||||
echo "ERROR: Failed to resolve feature directory" >&2
|
||||
else
|
||||
echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." >&2
|
||||
return 1
|
||||
fi
|
||||
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
|
||||
echo "ERROR: Failed to resolve feature directory" >&2
|
||||
else
|
||||
echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -291,7 +153,6 @@ get_feature_paths() {
|
||||
# via crafted branch names or paths containing special characters
|
||||
printf 'REPO_ROOT=%q\n' "$repo_root"
|
||||
printf 'CURRENT_BRANCH=%q\n' "$current_branch"
|
||||
printf 'HAS_GIT=%q\n' "$has_git_repo"
|
||||
printf 'FEATURE_DIR=%q\n' "$feature_dir"
|
||||
printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md"
|
||||
printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md"
|
||||
|
||||
@@ -57,9 +57,9 @@ while [ $i -le $# ]; do
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --json Output in JSON format"
|
||||
echo " --dry-run Compute branch name and paths without creating branches, directories, or files"
|
||||
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
|
||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
||||
echo " --dry-run Compute feature name and paths without creating directories or files"
|
||||
echo " --allow-existing-branch Reuse an existing feature directory if it already exists"
|
||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the feature"
|
||||
echo " --number N Specify branch number manually (overrides auto-detection)"
|
||||
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
echo " --help, -h Show this help message"
|
||||
@@ -113,94 +113,18 @@ get_highest_from_specs() {
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to get highest number from git branches
|
||||
get_highest_from_branches() {
|
||||
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
|
||||
}
|
||||
|
||||
# Extract the highest sequential feature number from a list of ref names (one per line).
|
||||
# Shared by get_highest_from_branches and get_highest_from_remote_refs.
|
||||
_extract_highest_number() {
|
||||
local highest=0
|
||||
while IFS= read -r name; do
|
||||
[ -z "$name" ] && continue
|
||||
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to get highest number from remote branches without fetching (side-effect-free)
|
||||
get_highest_from_remote_refs() {
|
||||
local highest=0
|
||||
|
||||
for remote in $(git remote 2>/dev/null); do
|
||||
local remote_highest
|
||||
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
|
||||
if [ "$remote_highest" -gt "$highest" ]; then
|
||||
highest=$remote_highest
|
||||
fi
|
||||
done
|
||||
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to check existing branches (local and remote) and return next available number.
|
||||
# When skip_fetch is true, queries remotes via ls-remote (read-only) instead of fetching.
|
||||
check_existing_branches() {
|
||||
local specs_dir="$1"
|
||||
local skip_fetch="${2:-false}"
|
||||
|
||||
if [ "$skip_fetch" = true ]; then
|
||||
# Side-effect-free: query remotes via ls-remote
|
||||
local highest_remote=$(get_highest_from_remote_refs)
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
if [ "$highest_remote" -gt "$highest_branch" ]; then
|
||||
highest_branch=$highest_remote
|
||||
fi
|
||||
else
|
||||
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
||||
git fetch --all --prune >/dev/null 2>&1 || true
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
fi
|
||||
|
||||
# Get highest number from ALL specs (not just matching short name)
|
||||
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
||||
|
||||
# Take the maximum of both
|
||||
local max_num=$highest_branch
|
||||
if [ "$highest_spec" -gt "$max_num" ]; then
|
||||
max_num=$highest_spec
|
||||
fi
|
||||
|
||||
# Return next number
|
||||
echo $((max_num + 1))
|
||||
}
|
||||
|
||||
# Function to clean and format a branch name
|
||||
clean_branch_name() {
|
||||
local name="$1"
|
||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||
}
|
||||
|
||||
# Resolve repository root using common.sh functions which prioritize .specify over git
|
||||
# Resolve repository root using common.sh functions which prioritize .specify
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
REPO_ROOT=$(get_repo_root)
|
||||
|
||||
# Check if git is available at this repo root (not a parent)
|
||||
if has_git; then
|
||||
HAS_GIT=true
|
||||
else
|
||||
HAS_GIT=false
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
SPECS_DIR="$REPO_ROOT/specs"
|
||||
@@ -276,23 +200,10 @@ if [ "$USE_TIMESTAMP" = true ]; then
|
||||
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
else
|
||||
# Determine branch number
|
||||
# Determine branch number from existing feature directories
|
||||
if [ -z "$BRANCH_NUMBER" ]; then
|
||||
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
|
||||
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
|
||||
elif [ "$DRY_RUN" = true ]; then
|
||||
# Dry-run without git: local spec dirs only
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
elif [ "$HAS_GIT" = true ]; then
|
||||
# Check existing branches on remotes
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
||||
else
|
||||
# Fall back to local directory check
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
fi
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
fi
|
||||
|
||||
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
|
||||
@@ -326,43 +237,13 @@ FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
||||
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
if [ "$HAS_GIT" = true ]; then
|
||||
branch_create_error=""
|
||||
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
|
||||
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
||||
# Check if branch already exists
|
||||
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
||||
if [ "$ALLOW_EXISTING" = true ]; then
|
||||
# If we're already on the branch, continue without another checkout.
|
||||
if [ "$current_branch" = "$BRANCH_NAME" ]; then
|
||||
:
|
||||
# Otherwise switch to the existing branch instead of failing.
|
||||
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
|
||||
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
|
||||
if [ -n "$switch_branch_error" ]; then
|
||||
>&2 printf '%s\n' "$switch_branch_error"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
elif [ "$USE_TIMESTAMP" = true ]; then
|
||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
|
||||
exit 1
|
||||
else
|
||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
|
||||
if [ -n "$branch_create_error" ]; then
|
||||
>&2 printf '%s\n' "$branch_create_error"
|
||||
else
|
||||
>&2 echo "Please check your git configuration and try again."
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
if [ -d "$FEATURE_DIR" ] && [ "$ALLOW_EXISTING" != true ]; then
|
||||
if [ "$USE_TIMESTAMP" = true ]; then
|
||||
>&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Rerun to get a new timestamp or use a different --short-name."
|
||||
else
|
||||
>&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Please use a different feature name or specify a different number with --number."
|
||||
fi
|
||||
else
|
||||
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
@@ -377,8 +258,12 @@ if [ "$DRY_RUN" != true ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Inform the user how to persist the feature variable in their own shell
|
||||
# Persist to .specify/feature.json so downstream commands can find the feature
|
||||
_persist_feature_json "$REPO_ROOT" "$FEATURE_DIR"
|
||||
|
||||
# Inform the user how to set feature state in their own shell
|
||||
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR" >&2
|
||||
fi
|
||||
|
||||
if $JSON_MODE; then
|
||||
@@ -409,5 +294,6 @@ else
|
||||
echo "FEATURE_NUM: $FEATURE_NUM"
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -32,11 +32,6 @@ _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature p
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
|
||||
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
fi
|
||||
|
||||
# Ensure the feature directory exists
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
@@ -75,17 +70,15 @@ if $JSON_MODE; then
|
||||
--arg impl_plan "$IMPL_PLAN" \
|
||||
--arg specs_dir "$FEATURE_DIR" \
|
||||
--arg branch "$CURRENT_BRANCH" \
|
||||
--arg has_git "$HAS_GIT" \
|
||||
'{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}'
|
||||
'{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch}'
|
||||
else
|
||||
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \
|
||||
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")"
|
||||
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s"}\n' \
|
||||
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")"
|
||||
fi
|
||||
else
|
||||
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
||||
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||
echo "SPECS_DIR: $FEATURE_DIR"
|
||||
echo "BRANCH: $CURRENT_BRANCH"
|
||||
echo "HAS_GIT: $HAS_GIT"
|
||||
fi
|
||||
|
||||
|
||||
@@ -27,12 +27,7 @@ _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature p
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
|
||||
# Validate branch
|
||||
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
fi
|
||||
|
||||
# Validate required files
|
||||
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2
|
||||
|
||||
@@ -81,11 +81,6 @@ if ($PathsOnly) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Validate branch name
|
||||
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Validate required directories and files
|
||||
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
|
||||
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"
|
||||
|
||||
@@ -24,8 +24,8 @@ function Find-SpecifyRoot {
|
||||
}
|
||||
}
|
||||
|
||||
# Get repository root, prioritizing .specify directory over git
|
||||
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
|
||||
# Get repository root, prioritizing .specify directory
|
||||
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
|
||||
function Get-RepoRoot {
|
||||
# First, look for .specify directory (spec-kit's own marker)
|
||||
$specifyRoot = Find-SpecifyRoot
|
||||
@@ -33,263 +33,81 @@ function Get-RepoRoot {
|
||||
return $specifyRoot
|
||||
}
|
||||
|
||||
# Fallback to git if no .specify found
|
||||
try {
|
||||
$result = git rev-parse --show-toplevel 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
return $result
|
||||
}
|
||||
} catch {
|
||||
# Git command failed
|
||||
}
|
||||
|
||||
# Final fallback to script location for non-git repos
|
||||
# Final fallback to script location
|
||||
# Use -LiteralPath to handle paths with wildcard characters
|
||||
return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path
|
||||
}
|
||||
|
||||
function Get-CurrentBranch {
|
||||
# First check if SPECIFY_FEATURE environment variable is set
|
||||
# Return feature name from explicit state only.
|
||||
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
|
||||
# the git extension) or implicitly via .specify/feature.json.
|
||||
if ($env:SPECIFY_FEATURE) {
|
||||
return $env:SPECIFY_FEATURE
|
||||
}
|
||||
|
||||
# Then check git if available at the spec-kit root (not parent)
|
||||
$repoRoot = Get-RepoRoot
|
||||
if (Test-HasGit) {
|
||||
# No explicit feature set - return empty to signal "unknown".
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Persist a feature_directory value to .specify/feature.json.
|
||||
# Writes only when the file is missing or the value differs from what's stored.
|
||||
function Save-FeatureJson {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||
[Parameter(Mandatory = $true)][string]$FeatureDirectory
|
||||
)
|
||||
|
||||
# Strip repo root prefix if the value is absolute and under repo root.
|
||||
# Use case-insensitive comparison on Windows only (case-sensitive filesystems elsewhere).
|
||||
$prefix = $RepoRoot + [System.IO.Path]::DirectorySeparatorChar
|
||||
if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true }
|
||||
if ($onWin) {
|
||||
$cmp = [System.StringComparison]::OrdinalIgnoreCase
|
||||
} else {
|
||||
$cmp = [System.StringComparison]::Ordinal
|
||||
}
|
||||
if ($FeatureDirectory.StartsWith($prefix, $cmp)) {
|
||||
$FeatureDirectory = $FeatureDirectory.Substring($prefix.Length)
|
||||
}
|
||||
|
||||
$fjPath = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
|
||||
|
||||
# Read current value and skip write when unchanged
|
||||
if (Test-Path -LiteralPath $fjPath -PathType Leaf) {
|
||||
try {
|
||||
$result = git -C $repoRoot rev-parse --abbrev-ref HEAD 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
return $result
|
||||
$raw = Get-Content -LiteralPath $fjPath -Raw
|
||||
$cfg = $raw | ConvertFrom-Json
|
||||
if ($cfg.feature_directory -eq $FeatureDirectory) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
# Git command failed
|
||||
# File is corrupt or unreadable - overwrite it
|
||||
}
|
||||
}
|
||||
|
||||
# For non-git repos, try to find the latest feature directory
|
||||
$specsDir = Join-Path $repoRoot "specs"
|
||||
|
||||
if (Test-Path $specsDir) {
|
||||
$latestFeature = ""
|
||||
$highest = 0
|
||||
$latestTimestamp = ""
|
||||
|
||||
Get-ChildItem -Path $specsDir -Directory | ForEach-Object {
|
||||
if ($_.Name -match '^(\d{8}-\d{6})-') {
|
||||
# Timestamp-based branch: compare lexicographically
|
||||
$ts = $matches[1]
|
||||
if ($ts -gt $latestTimestamp) {
|
||||
$latestTimestamp = $ts
|
||||
$latestFeature = $_.Name
|
||||
}
|
||||
} elseif ($_.Name -match '^(\d{3,})-') {
|
||||
$num = [long]$matches[1]
|
||||
if ($num -gt $highest) {
|
||||
$highest = $num
|
||||
# Only update if no timestamp branch found yet
|
||||
if (-not $latestTimestamp) {
|
||||
$latestFeature = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($latestFeature) {
|
||||
return $latestFeature
|
||||
}
|
||||
}
|
||||
|
||||
# Final fallback
|
||||
return "main"
|
||||
}
|
||||
|
||||
# Check if we have git available at the spec-kit root level
|
||||
# Returns true only if git is installed and the repo root is inside a git work tree
|
||||
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
|
||||
function Test-HasGit {
|
||||
# First check if git command is available (before calling Get-RepoRoot which may use git)
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||
return $false
|
||||
}
|
||||
$repoRoot = Get-RepoRoot
|
||||
# Check if .git exists (directory or file for worktrees/submodules)
|
||||
# Use -LiteralPath to handle paths with wildcard characters
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $repoRoot ".git"))) {
|
||||
return $false
|
||||
}
|
||||
# Verify it's actually a valid git work tree
|
||||
try {
|
||||
$null = git -C $repoRoot rev-parse --is-inside-work-tree 2>$null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
|
||||
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
|
||||
function Get-SpecKitEffectiveBranchName {
|
||||
param([string]$Branch)
|
||||
if ($Branch -match '^([^/]+)/([^/]+)$') {
|
||||
return $Matches[2]
|
||||
}
|
||||
return $Branch
|
||||
}
|
||||
|
||||
function Test-FeatureBranch {
|
||||
param(
|
||||
[string]$Branch,
|
||||
[bool]$HasGit = $true
|
||||
)
|
||||
|
||||
# For non-git repos, we can't enforce branch naming but still provide output
|
||||
if (-not $HasGit) {
|
||||
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
|
||||
return $true
|
||||
# Ensure .specify/ directory exists
|
||||
$specifyDir = Join-Path $RepoRoot '.specify'
|
||||
if (-not (Test-Path -LiteralPath $specifyDir -PathType Container)) {
|
||||
New-Item -ItemType Directory -Path $specifyDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$raw = $Branch
|
||||
$Branch = Get-SpecKitEffectiveBranchName $raw
|
||||
|
||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
|
||||
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
||||
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
|
||||
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
|
||||
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
|
||||
return $false
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
# True when .specify/feature.json pins an existing feature directory that matches the
|
||||
# active FEATURE_DIR from Get-FeaturePathsEnv (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks).
|
||||
function Test-FeatureJsonMatchesFeatureDir {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||
[Parameter(Mandatory = $true)][string]$ActiveFeatureDir
|
||||
)
|
||||
|
||||
$featureJson = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
|
||||
if (-not (Test-Path -LiteralPath $featureJson -PathType Leaf)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = Get-Content -LiteralPath $featureJson -Raw
|
||||
$cfg = $raw | ConvertFrom-Json
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
|
||||
$fd = $cfg.feature_directory
|
||||
if ([string]::IsNullOrWhiteSpace([string]$fd)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
if (-not [System.IO.Path]::IsPathRooted($fd)) {
|
||||
$fd = Join-Path $RepoRoot $fd
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $fd -PathType Container)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
# Resolve both paths to canonical absolute form. Prefer Resolve-Path (follows
|
||||
# symlinks and is the canonical PS way); fall back to [Path]::GetFullPath when
|
||||
# Resolve-Path can't produce a value. Mirrors the pattern used by Find-SpecifyRoot.
|
||||
$resolvedJson = Resolve-Path -LiteralPath $fd -ErrorAction SilentlyContinue
|
||||
if ($resolvedJson) {
|
||||
$normJson = $resolvedJson.Path
|
||||
} else {
|
||||
$normJson = [System.IO.Path]::GetFullPath($fd)
|
||||
}
|
||||
|
||||
$resolvedActive = Resolve-Path -LiteralPath $ActiveFeatureDir -ErrorAction SilentlyContinue
|
||||
if ($resolvedActive) {
|
||||
$normActive = $resolvedActive.Path
|
||||
} else {
|
||||
$normActive = [System.IO.Path]::GetFullPath($ActiveFeatureDir)
|
||||
}
|
||||
|
||||
# Use case-insensitive compare only on Windows; POSIX filesystems are case-sensitive.
|
||||
# PowerShell 5.1 is Windows-only and does not define $IsWindows, so treat its
|
||||
# absence as "we're on Windows".
|
||||
if ($null -ne $IsWindows) {
|
||||
$onWindows = $IsWindows
|
||||
} else {
|
||||
$onWindows = $true
|
||||
}
|
||||
|
||||
if ($onWindows) {
|
||||
$comparison = [System.StringComparison]::OrdinalIgnoreCase
|
||||
} else {
|
||||
$comparison = [System.StringComparison]::Ordinal
|
||||
}
|
||||
|
||||
return [string]::Equals($normJson, $normActive, $comparison)
|
||||
}
|
||||
|
||||
# Resolve specs/<feature-dir> by numeric/timestamp prefix (mirrors scripts/bash/common.sh find_feature_dir_by_prefix).
|
||||
function Find-FeatureDirByPrefix {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||
[Parameter(Mandatory = $true)][string]$Branch
|
||||
)
|
||||
$specsDir = Join-Path $RepoRoot 'specs'
|
||||
$branchName = Get-SpecKitEffectiveBranchName $Branch
|
||||
|
||||
$prefix = $null
|
||||
if ($branchName -match '^(\d{8}-\d{6})-') {
|
||||
$prefix = $Matches[1]
|
||||
} elseif ($branchName -match '^(\d{3,})-') {
|
||||
$prefix = $Matches[1]
|
||||
} else {
|
||||
return (Join-Path $specsDir $branchName)
|
||||
}
|
||||
|
||||
$dirMatches = @()
|
||||
if (Test-Path -LiteralPath $specsDir -PathType Container) {
|
||||
$dirMatches = @(Get-ChildItem -LiteralPath $specsDir -Filter "$prefix-*" -Directory -ErrorAction SilentlyContinue)
|
||||
}
|
||||
|
||||
if ($dirMatches.Count -eq 0) {
|
||||
return (Join-Path $specsDir $branchName)
|
||||
}
|
||||
if ($dirMatches.Count -eq 1) {
|
||||
return $dirMatches[0].FullName
|
||||
}
|
||||
$names = ($dirMatches | ForEach-Object { $_.Name }) -join ' '
|
||||
[Console]::Error.WriteLine("ERROR: Multiple spec directories found with prefix '$prefix': $names")
|
||||
[Console]::Error.WriteLine('Please ensure only one spec directory exists per prefix.')
|
||||
return $null
|
||||
}
|
||||
|
||||
# Branch-based prefix resolution; mirrors bash get_feature_paths failure (stderr + exit 1).
|
||||
function Get-FeatureDirFromBranchPrefixOrExit {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||
[Parameter(Mandatory = $true)][string]$CurrentBranch
|
||||
)
|
||||
$resolved = Find-FeatureDirByPrefix -RepoRoot $RepoRoot -Branch $CurrentBranch
|
||||
if ($null -eq $resolved) {
|
||||
[Console]::Error.WriteLine('ERROR: Failed to resolve feature directory')
|
||||
exit 1
|
||||
}
|
||||
return $resolved
|
||||
# Write feature.json
|
||||
$json = @{ feature_directory = $FeatureDirectory } | ConvertTo-Json -Compress
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($fjPath, $json, $utf8NoBom)
|
||||
}
|
||||
|
||||
function Get-FeaturePathsEnv {
|
||||
$repoRoot = Get-RepoRoot
|
||||
$currentBranch = Get-CurrentBranch
|
||||
$hasGit = Test-HasGit
|
||||
|
||||
# Resolve feature directory. Priority:
|
||||
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__)
|
||||
# 3. Branch-name-based prefix lookup (same as scripts/bash/common.sh)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by specify command)
|
||||
# 3. Error - no feature context available
|
||||
$featureJson = Join-Path $repoRoot '.specify/feature.json'
|
||||
if ($env:SPECIFY_FEATURE_DIRECTORY) {
|
||||
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY
|
||||
@@ -297,6 +115,8 @@ function Get-FeaturePathsEnv {
|
||||
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
|
||||
$featureDir = Join-Path $repoRoot $featureDir
|
||||
}
|
||||
# Persist to feature.json so future sessions without the env var still work
|
||||
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
|
||||
} elseif (Test-Path $featureJson) {
|
||||
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
|
||||
try {
|
||||
@@ -312,16 +132,17 @@ function Get-FeaturePathsEnv {
|
||||
$featureDir = Join-Path $repoRoot $featureDir
|
||||
}
|
||||
} else {
|
||||
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
REPO_ROOT = $repoRoot
|
||||
CURRENT_BRANCH = $currentBranch
|
||||
HAS_GIT = $hasGit
|
||||
FEATURE_DIR = $featureDir
|
||||
FEATURE_SPEC = Join-Path $featureDir 'spec.md'
|
||||
IMPL_PLAN = Join-Path $featureDir 'plan.md'
|
||||
|
||||
@@ -21,9 +21,9 @@ if ($Help) {
|
||||
Write-Host ""
|
||||
Write-Host "Options:"
|
||||
Write-Host " -Json Output in JSON format"
|
||||
Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files"
|
||||
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
|
||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
||||
Write-Host " -DryRun Compute feature name and paths without creating directories or files"
|
||||
Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
|
||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the feature"
|
||||
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
||||
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
Write-Host " -Help Show this help message"
|
||||
@@ -67,111 +67,17 @@ function Get-HighestNumberFromSpecs {
|
||||
return $highest
|
||||
}
|
||||
|
||||
# Extract the highest sequential feature number from a list of branch/ref names.
|
||||
# Shared by Get-HighestNumberFromBranches and Get-HighestNumberFromRemoteRefs.
|
||||
function Get-HighestNumberFromNames {
|
||||
param([string[]]$Names)
|
||||
|
||||
[long]$highest = 0
|
||||
foreach ($name in $Names) {
|
||||
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
|
||||
[long]$num = 0
|
||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||
$highest = $num
|
||||
}
|
||||
}
|
||||
}
|
||||
return $highest
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromBranches {
|
||||
param()
|
||||
|
||||
try {
|
||||
$branches = git branch -a 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $branches) {
|
||||
$cleanNames = $branches | ForEach-Object {
|
||||
$_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||
}
|
||||
return Get-HighestNumberFromNames -Names $cleanNames
|
||||
}
|
||||
} catch {
|
||||
Write-Verbose "Could not check Git branches: $_"
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromRemoteRefs {
|
||||
[long]$highest = 0
|
||||
try {
|
||||
$remotes = git remote 2>$null
|
||||
if ($remotes) {
|
||||
foreach ($remote in $remotes) {
|
||||
$env:GIT_TERMINAL_PROMPT = '0'
|
||||
$refs = git ls-remote --heads $remote 2>$null
|
||||
$env:GIT_TERMINAL_PROMPT = $null
|
||||
if ($LASTEXITCODE -eq 0 -and $refs) {
|
||||
$refNames = $refs | ForEach-Object {
|
||||
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
|
||||
} | Where-Object { $_ }
|
||||
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
|
||||
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Verbose "Could not query remote refs: $_"
|
||||
}
|
||||
return $highest
|
||||
}
|
||||
|
||||
# Return next available branch number. When SkipFetch is true, queries remotes
|
||||
# via ls-remote (read-only) instead of fetching.
|
||||
function Get-NextBranchNumber {
|
||||
param(
|
||||
[string]$SpecsDir,
|
||||
[switch]$SkipFetch
|
||||
)
|
||||
|
||||
if ($SkipFetch) {
|
||||
# Side-effect-free: query remotes via ls-remote
|
||||
$highestBranch = Get-HighestNumberFromBranches
|
||||
$highestRemote = Get-HighestNumberFromRemoteRefs
|
||||
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
|
||||
} else {
|
||||
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
||||
try {
|
||||
git fetch --all --prune 2>$null | Out-Null
|
||||
} catch {
|
||||
# Ignore fetch errors
|
||||
}
|
||||
$highestBranch = Get-HighestNumberFromBranches
|
||||
}
|
||||
|
||||
# Get highest number from ALL specs (not just matching short name)
|
||||
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
||||
|
||||
# Take the maximum of both
|
||||
$maxNum = [Math]::Max($highestBranch, $highestSpec)
|
||||
|
||||
# Return next number
|
||||
return $maxNum + 1
|
||||
}
|
||||
|
||||
function ConvertTo-CleanBranchName {
|
||||
param([string]$Name)
|
||||
|
||||
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
||||
}
|
||||
# Load common functions (includes Get-RepoRoot, Test-HasGit, Resolve-Template)
|
||||
# Load common functions (includes Get-RepoRoot and Resolve-Template)
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Use common.ps1 functions which prioritize .specify over git
|
||||
# Use common.ps1 functions which prioritize .specify
|
||||
$repoRoot = Get-RepoRoot
|
||||
|
||||
# Check if git is available at this repo root (not a parent)
|
||||
$hasGit = Test-HasGit
|
||||
|
||||
Set-Location $repoRoot
|
||||
|
||||
$specsDir = Join-Path $repoRoot 'specs'
|
||||
@@ -244,21 +150,9 @@ if ($Timestamp) {
|
||||
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
} else {
|
||||
# Determine branch number
|
||||
# Determine branch number from existing feature directories
|
||||
if ($Number -eq 0) {
|
||||
if ($DryRun -and $hasGit) {
|
||||
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
|
||||
} elseif ($DryRun) {
|
||||
# Dry-run without git: local spec dirs only
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
} elseif ($hasGit) {
|
||||
# Check existing branches on remotes
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
||||
} else {
|
||||
# Fall back to local directory check
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
}
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
}
|
||||
|
||||
$featureNum = ('{0:000}' -f $Number)
|
||||
@@ -291,58 +185,13 @@ $featureDir = Join-Path $specsDir $branchName
|
||||
$specFile = Join-Path $featureDir 'spec.md'
|
||||
|
||||
if (-not $DryRun) {
|
||||
if ($hasGit) {
|
||||
$branchCreated = $false
|
||||
$branchCreateError = ''
|
||||
try {
|
||||
$branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$branchCreated = $true
|
||||
}
|
||||
} catch {
|
||||
$branchCreateError = $_.Exception.Message
|
||||
if ((Test-Path -LiteralPath $featureDir -PathType Container) -and -not $AllowExistingBranch) {
|
||||
if ($Timestamp) {
|
||||
Write-Error "Error: Feature directory '$featureDir' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
||||
} else {
|
||||
Write-Error "Error: Feature directory '$featureDir' already exists. Please use a different feature name or specify a different number with -Number."
|
||||
}
|
||||
|
||||
if (-not $branchCreated) {
|
||||
$currentBranch = ''
|
||||
try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
|
||||
# Check if branch already exists
|
||||
$existingBranch = git branch --list $branchName 2>$null
|
||||
if ($existingBranch) {
|
||||
if ($AllowExistingBranch) {
|
||||
# If we're already on the branch, continue without another checkout.
|
||||
if ($currentBranch -eq $branchName) {
|
||||
# Already on the target branch -- nothing to do
|
||||
} else {
|
||||
# Otherwise switch to the existing branch instead of failing.
|
||||
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if ($switchBranchError) {
|
||||
Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
|
||||
} else {
|
||||
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
} elseif ($Timestamp) {
|
||||
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
||||
exit 1
|
||||
} else {
|
||||
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
if ($branchCreateError) {
|
||||
Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
|
||||
} else {
|
||||
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
|
||||
exit 1
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
||||
@@ -359,8 +208,12 @@ if (-not $DryRun) {
|
||||
}
|
||||
}
|
||||
|
||||
# Set the SPECIFY_FEATURE environment variable for the current session
|
||||
# Persist to .specify/feature.json so downstream commands can find the feature
|
||||
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $featureDir
|
||||
|
||||
# Set environment variables for the current session
|
||||
$env:SPECIFY_FEATURE = $branchName
|
||||
$env:SPECIFY_FEATURE_DIRECTORY = $featureDir
|
||||
}
|
||||
|
||||
if ($Json) {
|
||||
@@ -368,7 +221,6 @@ if ($Json) {
|
||||
BRANCH_NAME = $branchName
|
||||
SPEC_FILE = $specFile
|
||||
FEATURE_NUM = $featureNum
|
||||
HAS_GIT = $hasGit
|
||||
}
|
||||
if ($DryRun) {
|
||||
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
|
||||
@@ -378,8 +230,8 @@ if ($Json) {
|
||||
Write-Output "BRANCH_NAME: $branchName"
|
||||
Write-Output "SPEC_FILE: $specFile"
|
||||
Write-Output "FEATURE_NUM: $featureNum"
|
||||
Write-Output "HAS_GIT: $hasGit"
|
||||
if (-not $DryRun) {
|
||||
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
||||
Write-Output "SPECIFY_FEATURE set to: $branchName"
|
||||
Write-Output "SPECIFY_FEATURE_DIRECTORY set to: $featureDir"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,13 +23,6 @@ if ($Help) {
|
||||
# Get all paths and variables from common functions
|
||||
$paths = Get-FeaturePathsEnv
|
||||
|
||||
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||
if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
|
||||
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure the feature directory exists
|
||||
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
||||
|
||||
@@ -61,7 +54,6 @@ if ($Json) {
|
||||
IMPL_PLAN = $paths.IMPL_PLAN
|
||||
SPECS_DIR = $paths.FEATURE_DIR
|
||||
BRANCH = $paths.CURRENT_BRANCH
|
||||
HAS_GIT = $paths.HAS_GIT
|
||||
}
|
||||
$result | ConvertTo-Json -Compress
|
||||
} else {
|
||||
@@ -69,5 +61,4 @@ if ($Json) {
|
||||
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
|
||||
Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)"
|
||||
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
|
||||
Write-Output "HAS_GIT: $($paths.HAS_GIT)"
|
||||
}
|
||||
|
||||
@@ -16,16 +16,9 @@ if ($Help) {
|
||||
# Source common functions
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get feature paths and validate branch
|
||||
# Get feature paths
|
||||
$paths = Get-FeaturePathsEnv
|
||||
|
||||
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||
if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
|
||||
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
||||
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT
|
||||
|
||||
@@ -69,8 +69,6 @@ from ._utils import (
|
||||
_display_project_path,
|
||||
check_tool as check_tool,
|
||||
handle_vscode_settings as handle_vscode_settings,
|
||||
init_git_repo as init_git_repo,
|
||||
is_git_repo as is_git_repo,
|
||||
merge_json_files as merge_json_files,
|
||||
run_command as run_command,
|
||||
)
|
||||
@@ -453,9 +451,6 @@ def check():
|
||||
|
||||
tracker = StepTracker("Check Available Tools")
|
||||
|
||||
tracker.add("git", "Git version control")
|
||||
git_ok = check_tool("git", tracker=tracker)
|
||||
|
||||
agent_results = {}
|
||||
for agent_key, agent_config in AGENT_CONFIG.items():
|
||||
if agent_key == "generic":
|
||||
@@ -483,9 +478,6 @@ def check():
|
||||
|
||||
console.print("\n[bold green]Specify CLI is ready to use![/bold green]")
|
||||
|
||||
if not git_ok:
|
||||
console.print("[dim]Tip: Install git for repository management[/dim]")
|
||||
|
||||
if not any(agent_results.values()):
|
||||
console.print("[dim]Tip: Install a coding agent for the best experience[/dim]")
|
||||
|
||||
|
||||
@@ -77,51 +77,6 @@ def check_tool(tool: str, tracker=None) -> bool:
|
||||
return found
|
||||
|
||||
|
||||
def is_git_repo(path: Path | None = 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
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "rev-parse", "--is-inside-work-tree"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
cwd=path,
|
||||
)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return False
|
||||
|
||||
|
||||
def init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, str | None]:
|
||||
"""Initialize a git repository in the specified path."""
|
||||
try:
|
||||
original_cwd = Path.cwd()
|
||||
os.chdir(project_path)
|
||||
if not quiet:
|
||||
console.print("[cyan]Initializing git repository...[/cyan]")
|
||||
subprocess.run(["git", "init"], check=True, capture_output=True, text=True)
|
||||
subprocess.run(["git", "add", "."], check=True, capture_output=True, text=True)
|
||||
subprocess.run(["git", "commit", "-m", "Initial commit from Specify template"], check=True, capture_output=True, text=True)
|
||||
if not quiet:
|
||||
console.print("[green]✓[/green] Git repository initialized")
|
||||
return True, None
|
||||
except subprocess.CalledProcessError as e:
|
||||
error_msg = f"Command: {' '.join(e.cmd)}\nExit code: {e.returncode}"
|
||||
if e.stderr:
|
||||
error_msg += f"\nError: {e.stderr.strip()}"
|
||||
elif e.stdout:
|
||||
error_msg += f"\nOutput: {e.stdout.strip()}"
|
||||
if not quiet:
|
||||
console.print(f"[red]Error initializing git repository:[/red] {e}")
|
||||
return False, error_msg
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
|
||||
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
|
||||
"""Handle merging or copying of .vscode/settings.json files.
|
||||
|
||||
@@ -23,7 +23,7 @@ from .._assets import (
|
||||
get_speckit_version,
|
||||
)
|
||||
from .._console import StepTracker, console, select_with_arrows, show_banner
|
||||
from .._utils import check_tool, init_git_repo, is_git_repo
|
||||
from .._utils import check_tool
|
||||
|
||||
|
||||
def _stdin_is_interactive() -> bool:
|
||||
@@ -71,7 +71,6 @@ def register(app: typer.Typer) -> None:
|
||||
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
|
||||
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
|
||||
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding 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"),
|
||||
force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"),
|
||||
skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True),
|
||||
@@ -79,7 +78,6 @@ def register(app: typer.Typer) -> None:
|
||||
github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True),
|
||||
offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True),
|
||||
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
|
||||
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"),
|
||||
integration: str = typer.Option(None, "--integration", help="AI coding agent integration to use (e.g. --integration copilot). See 'specify check' for available integrations."),
|
||||
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
|
||||
):
|
||||
@@ -91,18 +89,16 @@ def register(app: typer.Typer) -> None:
|
||||
match the installed CLI version.
|
||||
|
||||
This command will:
|
||||
1. Check that required tools are installed (git is optional)
|
||||
1. Check that required tools are installed
|
||||
2. Let you choose your coding agent integration, or default to Copilot
|
||||
in non-interactive sessions
|
||||
3. Install bundled Spec Kit templates, scripts, workflow, and shared
|
||||
project infrastructure
|
||||
4. Initialize a fresh git repository (if not --no-git and no existing repo)
|
||||
5. Set up coding agent integration commands and optional presets
|
||||
4. Set up coding agent integration commands and optional presets
|
||||
|
||||
Examples:
|
||||
specify init my-project
|
||||
specify init my-project --integration claude
|
||||
specify init my-project --integration copilot --no-git
|
||||
specify init --ignore-agent-tools my-project
|
||||
specify init . --integration claude # Initialize in current directory
|
||||
specify init . # Initialize in current directory (interactive integration selection)
|
||||
@@ -142,13 +138,6 @@ def register(app: typer.Typer) -> None:
|
||||
console.print(f"[yellow]Available integrations:[/yellow] {available}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if no_git:
|
||||
console.print(
|
||||
"[yellow]⚠️ --no-git is deprecated and will be removed in v0.10.0.[/yellow]\n"
|
||||
"[yellow]The git extension will no longer be enabled by default "
|
||||
"— use the [bold]specify extension[/bold] commands to install or enable the git extension if needed.[/yellow]"
|
||||
)
|
||||
|
||||
if project_name == ".":
|
||||
here = True
|
||||
project_name = None
|
||||
@@ -161,10 +150,7 @@ def register(app: typer.Typer) -> None:
|
||||
console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag")
|
||||
raise typer.Exit(1)
|
||||
|
||||
BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"}
|
||||
if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES:
|
||||
console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
dir_existed_before = False
|
||||
if here:
|
||||
@@ -253,12 +239,6 @@ def register(app: typer.Typer) -> None:
|
||||
|
||||
console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2)))
|
||||
|
||||
should_init_git = False
|
||||
if not no_git:
|
||||
should_init_git = check_tool("git")
|
||||
if not should_init_git:
|
||||
console.print("[yellow]Git not found - will skip repository initialization[/yellow]")
|
||||
|
||||
if not ignore_agent_tools:
|
||||
agent_config = AGENT_CONFIG.get(selected_ai)
|
||||
if agent_config and agent_config["requires_cli"]:
|
||||
@@ -308,15 +288,12 @@ def register(app: typer.Typer) -> None:
|
||||
for key, label in [
|
||||
("chmod", "Ensure scripts executable"),
|
||||
("constitution", "Constitution setup"),
|
||||
("git", "Install git extension"),
|
||||
("workflow", "Install bundled workflow"),
|
||||
("agent-context", "Install agent-context extension"),
|
||||
("final", "Finalize"),
|
||||
]:
|
||||
tracker.add(key, label)
|
||||
|
||||
git_default_notice = False
|
||||
|
||||
with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live:
|
||||
tracker.attach_refresh(lambda: live.update(tracker.render()))
|
||||
try:
|
||||
@@ -369,55 +346,6 @@ def register(app: typer.Typer) -> None:
|
||||
|
||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||
|
||||
if not no_git:
|
||||
tracker.start("git")
|
||||
git_messages = []
|
||||
git_has_error = False
|
||||
if is_git_repo(project_path):
|
||||
git_messages.append("existing repo detected")
|
||||
elif should_init_git:
|
||||
success, error_msg = init_git_repo(project_path, quiet=True)
|
||||
if success:
|
||||
git_messages.append("initialized")
|
||||
else:
|
||||
git_has_error = True
|
||||
if error_msg:
|
||||
sanitized = error_msg.replace('\n', ' ').strip()
|
||||
git_messages.append(f"init failed: {sanitized[:120]}")
|
||||
else:
|
||||
git_messages.append("init failed")
|
||||
else:
|
||||
git_messages.append("git not available")
|
||||
try:
|
||||
from ..extensions import ExtensionManager
|
||||
bundled_path = _locate_bundled_extension("git")
|
||||
if bundled_path:
|
||||
manager = ExtensionManager(project_path)
|
||||
if manager.registry.is_installed("git"):
|
||||
git_messages.append("extension already installed")
|
||||
else:
|
||||
manager.install_from_directory(
|
||||
bundled_path, get_speckit_version()
|
||||
)
|
||||
git_default_notice = True
|
||||
git_messages.append("extension installed")
|
||||
else:
|
||||
git_has_error = True
|
||||
git_messages.append("bundled extension not found")
|
||||
except Exception as ext_err:
|
||||
git_has_error = True
|
||||
sanitized_ext = str(ext_err).replace('\n', ' ').strip()
|
||||
git_messages.append(
|
||||
f"extension install failed: {sanitized_ext[:120]}"
|
||||
)
|
||||
summary = "; ".join(git_messages)
|
||||
if git_has_error:
|
||||
tracker.error("git", summary)
|
||||
else:
|
||||
tracker.complete("git", summary)
|
||||
else:
|
||||
tracker.skip("git", "--no-git flag")
|
||||
|
||||
try:
|
||||
bundled_wf = _locate_bundled_workflow("speckit")
|
||||
if bundled_wf:
|
||||
@@ -451,9 +379,9 @@ def register(app: typer.Typer) -> None:
|
||||
init_opts = {
|
||||
"ai": selected_ai,
|
||||
"integration": resolved_integration.key,
|
||||
"branch_numbering": branch_numbering or "sequential",
|
||||
"here": here,
|
||||
"script": selected_script,
|
||||
"feature_numbering": "sequential",
|
||||
"speckit_version": get_speckit_version(),
|
||||
}
|
||||
from ..integrations.base import SkillsIntegration as _SkillsPersist
|
||||
@@ -596,18 +524,6 @@ def register(app: typer.Typer) -> None:
|
||||
console.print()
|
||||
console.print(security_notice)
|
||||
|
||||
if git_default_notice:
|
||||
default_change_notice = Panel(
|
||||
"The git extension is currently enabled by default during [bold]specify init[/bold].\n"
|
||||
"Starting in [bold]v0.10.0[/bold], this will require explicit opt-in.\n"
|
||||
"Use [bold]specify extension add git[/bold] after init when needed.",
|
||||
title="[yellow]Notice: Git Default Changing[/yellow]",
|
||||
border_style="yellow",
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print()
|
||||
console.print(default_change_notice)
|
||||
|
||||
steps_lines = []
|
||||
if not here:
|
||||
steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]")
|
||||
|
||||
@@ -83,11 +83,12 @@ Given that feature description, do this:
|
||||
**Resolution order for `SPECIFY_FEATURE_DIRECTORY`**:
|
||||
1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is
|
||||
2. Otherwise, auto-generate it under `specs/`:
|
||||
- Check `.specify/init-options.json` for `branch_numbering`
|
||||
- Check `.specify/init-options.json` for `feature_numbering` (preferred) or `branch_numbering` (deprecated, migration only — will be removed in a future release)
|
||||
- If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp)
|
||||
- If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`)
|
||||
- Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||
- Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>`
|
||||
- If `branch_numbering` was used (and `feature_numbering` was absent), emit a one-line warning: "⚠️ `branch_numbering` in init-options.json is deprecated. Rename to `feature_numbering`."
|
||||
|
||||
**Create the directory and spec file**:
|
||||
- `mkdir -p SPECIFY_FEATURE_DIRECTORY`
|
||||
|
||||
@@ -3,7 +3,7 @@ Tests for the bundled git extension (extensions/git/).
|
||||
|
||||
Validates:
|
||||
- extension.yml manifest
|
||||
- Bash scripts (create-new-feature.sh, initialize-repo.sh, auto-commit.sh, git-common.sh)
|
||||
- Bash scripts (create-new-feature-branch.sh, initialize-repo.sh, auto-commit.sh, git-common.sh)
|
||||
- PowerShell scripts (where pwsh is available)
|
||||
- Config reading from git-config.yml
|
||||
- Extension install via ExtensionManager
|
||||
@@ -193,11 +193,11 @@ class TestGitExtensionInstall:
|
||||
manager.install_from_directory(EXT_DIR, "0.5.0", register_commands=False)
|
||||
|
||||
ext_installed = tmp_path / ".specify" / "extensions" / "git"
|
||||
assert (ext_installed / "scripts" / "bash" / "create-new-feature.sh").is_file()
|
||||
assert (ext_installed / "scripts" / "bash" / "create-new-feature-branch.sh").is_file()
|
||||
assert (ext_installed / "scripts" / "bash" / "initialize-repo.sh").is_file()
|
||||
assert (ext_installed / "scripts" / "bash" / "auto-commit.sh").is_file()
|
||||
assert (ext_installed / "scripts" / "bash" / "git-common.sh").is_file()
|
||||
assert (ext_installed / "scripts" / "powershell" / "create-new-feature.ps1").is_file()
|
||||
assert (ext_installed / "scripts" / "powershell" / "create-new-feature-branch.ps1").is_file()
|
||||
assert (ext_installed / "scripts" / "powershell" / "initialize-repo.ps1").is_file()
|
||||
assert (ext_installed / "scripts" / "powershell" / "auto-commit.ps1").is_file()
|
||||
assert (ext_installed / "scripts" / "powershell" / "git-common.ps1").is_file()
|
||||
@@ -270,16 +270,16 @@ class TestInitializeRepoPowerShell:
|
||||
assert result.returncode == 0
|
||||
|
||||
|
||||
# ── create-new-feature.sh Tests ──────────────────────────────────────────────
|
||||
# ── create-new-feature-branch.sh Tests ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestCreateFeatureBash:
|
||||
def test_creates_branch_sequential(self, tmp_path: Path):
|
||||
"""Extension create-new-feature.sh creates sequential branch."""
|
||||
"""Extension create-new-feature-branch.sh creates sequential branch."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_bash(
|
||||
"create-new-feature.sh", project,
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--short-name", "user-auth", "Add user authentication",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -288,10 +288,10 @@ class TestCreateFeatureBash:
|
||||
assert data["FEATURE_NUM"] == "001"
|
||||
|
||||
def test_creates_branch_timestamp(self, tmp_path: Path):
|
||||
"""Extension create-new-feature.sh creates timestamp branch."""
|
||||
"""Extension create-new-feature-branch.sh creates timestamp branch."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_bash(
|
||||
"create-new-feature.sh", project,
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--timestamp", "--short-name", "feat", "Feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -305,7 +305,7 @@ class TestCreateFeatureBash:
|
||||
(project / "specs" / "002-second").mkdir(parents=True)
|
||||
|
||||
result = _run_bash(
|
||||
"create-new-feature.sh", project,
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--short-name", "third", "Third feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -313,10 +313,10 @@ class TestCreateFeatureBash:
|
||||
assert data["FEATURE_NUM"] == "003"
|
||||
|
||||
def test_no_git_graceful_degradation(self, tmp_path: Path):
|
||||
"""create-new-feature.sh works without git (outputs branch name, skips branch creation)."""
|
||||
"""create-new-feature-branch.sh works without git (outputs branch name, skips branch creation)."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
result = _run_bash(
|
||||
"create-new-feature.sh", project,
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--short-name", "no-git", "No git feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -329,7 +329,7 @@ class TestCreateFeatureBash:
|
||||
"""--dry-run computes branch name without creating anything."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_bash(
|
||||
"create-new-feature.sh", project,
|
||||
"create-new-feature-branch.sh", project,
|
||||
"--json", "--dry-run", "--short-name", "dry", "Dry run test",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -341,10 +341,10 @@ class TestCreateFeatureBash:
|
||||
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
||||
class TestCreateFeaturePowerShell:
|
||||
def test_creates_branch_sequential(self, tmp_path: Path):
|
||||
"""Extension create-new-feature.ps1 creates sequential branch."""
|
||||
"""Extension create-new-feature-branch.ps1 creates sequential branch."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_pwsh(
|
||||
"create-new-feature.ps1", project,
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-ShortName", "user-auth", "Add user authentication",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -352,10 +352,10 @@ class TestCreateFeaturePowerShell:
|
||||
assert data["BRANCH_NAME"] == "001-user-auth"
|
||||
|
||||
def test_creates_branch_timestamp(self, tmp_path: Path):
|
||||
"""Extension create-new-feature.ps1 creates timestamp branch."""
|
||||
"""Extension create-new-feature-branch.ps1 creates timestamp branch."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_pwsh(
|
||||
"create-new-feature.ps1", project,
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-Timestamp", "-ShortName", "feat", "Feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -363,10 +363,10 @@ class TestCreateFeaturePowerShell:
|
||||
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
|
||||
|
||||
def test_no_git_graceful_degradation(self, tmp_path: Path):
|
||||
"""create-new-feature.ps1 works without git."""
|
||||
"""create-new-feature-branch.ps1 works without git."""
|
||||
project = _setup_project(tmp_path, git=False)
|
||||
result = _run_pwsh(
|
||||
"create-new-feature.ps1", project,
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-Json", "-ShortName", "no-git", "No git feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
@@ -63,7 +63,7 @@ class TestInitIntegrationFlag:
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git",
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -111,7 +111,7 @@ class TestInitIntegrationFlag:
|
||||
runner = CliRunner()
|
||||
project = tmp_path / "noninteractive"
|
||||
result = runner.invoke(app, [
|
||||
"init", str(project), "--script", "sh", "--no-git", "--ignore-agent-tools",
|
||||
"init", str(project), "--script", "sh", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
@@ -131,7 +131,7 @@ class TestInitIntegrationFlag:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git",
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -160,7 +160,6 @@ class TestInitIntegrationFlag:
|
||||
"copilot",
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--preset",
|
||||
"lean",
|
||||
],
|
||||
@@ -192,7 +191,7 @@ class TestInitIntegrationFlag:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--force", "--integration", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools",
|
||||
"init", "--here", "--force", "--integration", "claude", "--script", "sh", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -633,7 +632,6 @@ class TestInitIntegrationFlag:
|
||||
"init", "--here", "--force",
|
||||
"--integration", "copilot",
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -663,7 +661,6 @@ class TestInitIntegrationFlag:
|
||||
"init", "--here",
|
||||
"--integration", "copilot",
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
], input="y\n", catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -692,7 +689,7 @@ class TestForceExistingDirectory:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", str(target), "--integration", "copilot", "--force",
|
||||
"--no-git", "--script", "sh",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
|
||||
assert result.exit_code == 0, f"init --force failed: {result.output}"
|
||||
@@ -715,22 +712,22 @@ class TestForceExistingDirectory:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", str(target), "--integration", "copilot",
|
||||
"--no-git", "--script", "sh",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "already exists" in _normalize_cli_output(result.output)
|
||||
|
||||
|
||||
class TestGitExtensionAutoInstall:
|
||||
"""Tests for auto-installation of the git extension during specify init."""
|
||||
class TestGitExtensionOptIn:
|
||||
"""Tests verifying that the git extension is opt-in (not auto-installed) during specify init."""
|
||||
|
||||
def test_git_extension_auto_installed(self, tmp_path):
|
||||
"""Without --no-git, the git extension is installed during init."""
|
||||
def test_git_extension_not_auto_installed(self, tmp_path):
|
||||
"""Git extension is NOT installed automatically during init."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "git-auto"
|
||||
project = tmp_path / "git-opt-in"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
@@ -745,30 +742,16 @@ class TestGitExtensionAutoInstall:
|
||||
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
|
||||
# Check that the tracker didn't report a git error
|
||||
assert "install failed" not in result.output, f"git extension install failed: {result.output}"
|
||||
|
||||
# Git extension files should be installed
|
||||
# Git extension directory should NOT be present after init
|
||||
ext_dir = project / ".specify" / "extensions" / "git"
|
||||
assert ext_dir.exists(), "git extension directory not installed"
|
||||
assert (ext_dir / "extension.yml").exists()
|
||||
assert (ext_dir / "scripts" / "bash" / "create-new-feature.sh").exists()
|
||||
assert (ext_dir / "scripts" / "bash" / "initialize-repo.sh").exists()
|
||||
assert not ext_dir.exists(), "git extension should not be auto-installed"
|
||||
|
||||
# Hooks should be registered
|
||||
extensions_yml = project / ".specify" / "extensions.yml"
|
||||
assert extensions_yml.exists(), "extensions.yml not created"
|
||||
hooks_data = yaml.safe_load(extensions_yml.read_text(encoding="utf-8"))
|
||||
assert "hooks" in hooks_data
|
||||
assert "before_specify" in hooks_data["hooks"]
|
||||
assert "before_constitution" in hooks_data["hooks"]
|
||||
|
||||
def test_no_git_skips_extension(self, tmp_path):
|
||||
"""With --no-git, the git extension is NOT installed."""
|
||||
def test_no_git_flag_is_rejected(self, tmp_path):
|
||||
"""--no-git flag has been removed; passing it should fail."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "no-git"
|
||||
project = tmp_path / "no-git-rejected"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
@@ -777,75 +760,19 @@ class TestGitExtensionAutoInstall:
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", "claude", "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
assert result.exit_code != 0, "--no-git should be rejected as an unknown option"
|
||||
assert "No such option" in result.output or "no such option" in result.output.lower()
|
||||
|
||||
# Git extension should NOT be installed
|
||||
ext_dir = project / ".specify" / "extensions" / "git"
|
||||
assert not ext_dir.exists(), "git extension should not be installed with --no-git"
|
||||
|
||||
def test_no_git_emits_deprecation_warning(self, tmp_path):
|
||||
"""Using --no-git emits a visible deprecation warning."""
|
||||
def test_git_extension_commands_not_registered_by_default(self, tmp_path):
|
||||
"""Git extension commands are NOT registered with the agent during default init."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "no-git-warn"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", "claude", "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "--no-git" in normalized_output
|
||||
assert "deprecated" in normalized_output
|
||||
assert "0.10.0" in normalized_output
|
||||
assert "specify extension" in normalized_output
|
||||
assert "will be removed" in normalized_output
|
||||
assert "git extension will no longer be enabled by default" in normalized_output
|
||||
|
||||
def test_default_git_auto_enable_emits_notice(self, tmp_path):
|
||||
"""Default git auto-enable emits notice about the v0.10.0 opt-in change."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "git-default-notice"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", "claude", "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 0, result.output
|
||||
# Check for key message components (notice may have box-drawing chars)
|
||||
assert "git extension is currently enabled by default" in normalized_output
|
||||
assert "v0.10.0" in normalized_output
|
||||
assert "explicit opt-in" in normalized_output
|
||||
assert "specify extension add git" in normalized_output
|
||||
|
||||
def test_git_extension_commands_registered(self, tmp_path):
|
||||
"""Git extension commands are registered with the agent during init."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "git-cmds"
|
||||
project = tmp_path / "git-cmds-absent"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
@@ -860,11 +787,11 @@ class TestGitExtensionAutoInstall:
|
||||
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
|
||||
# Git extension commands should be registered with the agent
|
||||
# Git extension skill commands should NOT be present
|
||||
claude_skills = project / ".claude" / "skills"
|
||||
assert claude_skills.exists(), "Claude skills directory was not created"
|
||||
git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")]
|
||||
assert len(git_skills) > 0, "no git extension commands registered"
|
||||
assert len(git_skills) == 0, "git extension commands should not be registered by default"
|
||||
|
||||
|
||||
class TestSharedInfraCommandRefs:
|
||||
@@ -983,7 +910,6 @@ class TestSharedInfraCommandRefs:
|
||||
"init", str(project),
|
||||
"--integration", "claude",
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -1014,7 +940,6 @@ class TestSharedInfraCommandRefs:
|
||||
"init", str(project),
|
||||
"--integration", "copilot",
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -1046,7 +971,6 @@ class TestSharedInfraCommandRefs:
|
||||
"--integration", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
|
||||
@@ -39,7 +39,7 @@ class TestAgyInitFlow:
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--script", "sh", "--ignore-agent-tools"])
|
||||
|
||||
assert result.exit_code == 0, f"init --integration agy failed: {result.output}"
|
||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
@@ -52,7 +52,7 @@ class TestAgyInitFlow:
|
||||
# Click >= 8.2 separates stdout and stderr natively
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj2"
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--script", "sh", "--ignore-agent-tools"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr
|
||||
|
||||
@@ -192,7 +192,7 @@ class MarkdownIntegrationTests:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -213,7 +213,7 @@ class MarkdownIntegrationTests:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -238,7 +238,7 @@ class MarkdownIntegrationTests:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -321,13 +321,13 @@ class MarkdownIntegrationTests:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file())
|
||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
||||
expected = self._expected_files("sh")
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
@@ -346,13 +346,13 @@ class MarkdownIntegrationTests:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "ps",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file())
|
||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
||||
expected = self._expected_files("ps")
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
|
||||
@@ -325,7 +325,7 @@ class SkillsIntegrationTests:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -346,7 +346,7 @@ class SkillsIntegrationTests:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -369,7 +369,7 @@ class SkillsIntegrationTests:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -471,15 +471,15 @@ class SkillsIntegrationTests:
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY,
|
||||
"--script", "sh", "--no-git", "--ignore-agent-tools",
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file()
|
||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
||||
)
|
||||
expected = self._expected_files("sh")
|
||||
assert actual == expected, (
|
||||
@@ -498,15 +498,15 @@ class SkillsIntegrationTests:
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY,
|
||||
"--script", "ps", "--no-git", "--ignore-agent-tools",
|
||||
"init", "--here", "--integration", self.KEY, "--script", "ps",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file()
|
||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
||||
)
|
||||
expected = self._expected_files("ps")
|
||||
assert actual == expected, (
|
||||
|
||||
@@ -409,7 +409,6 @@ class TomlIntegrationTests:
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -440,7 +439,6 @@ class TomlIntegrationTests:
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -469,7 +467,7 @@ class TomlIntegrationTests:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -580,7 +578,6 @@ class TomlIntegrationTests:
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -589,7 +586,7 @@ class TomlIntegrationTests:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
||||
)
|
||||
expected = self._expected_files("sh")
|
||||
assert actual == expected, (
|
||||
@@ -616,7 +613,6 @@ class TomlIntegrationTests:
|
||||
self.KEY,
|
||||
"--script",
|
||||
"ps",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -625,7 +621,7 @@ class TomlIntegrationTests:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
||||
)
|
||||
expected = self._expected_files("ps")
|
||||
assert actual == expected, (
|
||||
|
||||
@@ -288,7 +288,6 @@ class YamlIntegrationTests:
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -319,7 +318,6 @@ class YamlIntegrationTests:
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -348,7 +346,7 @@ class YamlIntegrationTests:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -459,7 +457,6 @@ class YamlIntegrationTests:
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -468,7 +465,7 @@ class YamlIntegrationTests:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
||||
)
|
||||
expected = self._expected_files("sh")
|
||||
assert actual == expected, (
|
||||
@@ -495,7 +492,6 @@ class YamlIntegrationTests:
|
||||
self.KEY,
|
||||
"--script",
|
||||
"ps",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -504,7 +500,7 @@ class YamlIntegrationTests:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
||||
)
|
||||
expected = self._expected_files("ps")
|
||||
assert actual == expected, (
|
||||
|
||||
@@ -458,7 +458,6 @@ class TestIntegrationListCatalog:
|
||||
"init", "--here",
|
||||
"--integration", "copilot",
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -556,7 +555,6 @@ class TestIntegrationUpgrade:
|
||||
"init", "--here",
|
||||
"--integration", integration,
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
|
||||
@@ -137,7 +137,6 @@ class TestClaudeIntegration:
|
||||
"claude",
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -175,7 +174,6 @@ class TestClaudeIntegration:
|
||||
"claude",
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -208,7 +206,6 @@ class TestClaudeIntegration:
|
||||
"--here",
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
@@ -243,7 +240,7 @@ class TestClaudeIntegration:
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--integration", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools"],
|
||||
["init", str(target), "--integration", "claude", "--script", "sh", "--ignore-agent-tools"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -139,7 +139,6 @@ class TestClineIntegration(MarkdownIntegrationTests):
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
|
||||
@@ -24,7 +24,7 @@ class TestCodexInitFlow:
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "codex", "--no-git", "--ignore-agent-tools", "--script", "sh"])
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "codex", "--ignore-agent-tools", "--script", "sh"])
|
||||
|
||||
assert result.exit_code == 0, f"init --integration codex failed: {result.output}"
|
||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
@@ -186,12 +186,12 @@ class TestCopilotIntegration:
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git",
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
||||
expected = sorted([
|
||||
".github/agents/speckit.agent-context.update.agent.md",
|
||||
".github/agents/speckit.analyze.agent.md",
|
||||
@@ -256,12 +256,12 @@ class TestCopilotIntegration:
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "ps", "--no-git",
|
||||
"init", "--here", "--integration", "copilot", "--script", "ps",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
||||
expected = sorted([
|
||||
".github/agents/speckit.agent-context.update.agent.md",
|
||||
".github/agents/speckit.analyze.agent.md",
|
||||
@@ -622,7 +622,7 @@ class TestCopilotSkillsMode:
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh", "--no-git",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -648,12 +648,12 @@ class TestCopilotSkillsMode:
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh", "--no-git",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
||||
expected = sorted([
|
||||
# Skill files (core + extension-installed agent-context command)
|
||||
*[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS],
|
||||
@@ -775,7 +775,6 @@ class TestCopilotSkillsMode:
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh", "--no-git",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
@@ -102,7 +102,7 @@ class TestCursorAgentInitFlow:
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "cursor-agent", "--no-git", "--ignore-agent-tools", "--script", "sh"])
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "cursor-agent", "--ignore-agent-tools", "--script", "sh"])
|
||||
|
||||
assert result.exit_code == 0, f"init --integration cursor-agent failed: {result.output}"
|
||||
assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
@@ -68,7 +68,7 @@ class TestDevinInitFlow:
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--integration", "devin", "--no-git", "--ignore-agent-tools", "--script", "sh"],
|
||||
["init", str(target), "--integration", "devin", "--ignore-agent-tools", "--script", "sh"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"init --integration devin failed: {result.output}"
|
||||
|
||||
@@ -251,7 +251,6 @@ class TestGenericIntegration:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", str(tmp_path / "test-generic"), "--integration", "generic",
|
||||
"--script", "sh", "--no-git",
|
||||
])
|
||||
# Generic requires --commands-dir via --integration-options
|
||||
assert result.exit_code != 0
|
||||
@@ -270,7 +269,7 @@ class TestGenericIntegration:
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "generic",
|
||||
"--integration-options=--commands-dir .myagent/commands",
|
||||
"--script", "sh", "--no-git",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -292,14 +291,14 @@ class TestGenericIntegration:
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "generic",
|
||||
"--integration-options=--commands-dir .myagent/commands",
|
||||
"--script", "sh", "--no-git",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file()
|
||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
||||
)
|
||||
expected = sorted([
|
||||
"AGENTS.md",
|
||||
@@ -356,14 +355,14 @@ class TestGenericIntegration:
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "generic",
|
||||
"--integration-options=--commands-dir .myagent/commands",
|
||||
"--script", "ps", "--no-git",
|
||||
"--script", "ps",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file()
|
||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
||||
)
|
||||
expected = sorted([
|
||||
"AGENTS.md",
|
||||
|
||||
@@ -232,7 +232,7 @@ class TestHermesIntegration(SkillsIntegrationTests):
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY,
|
||||
"--script", "sh", "--no-git", "--ignore-agent-tools",
|
||||
"--script", "sh", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -270,7 +270,7 @@ class TestHermesIntegration(SkillsIntegrationTests):
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY,
|
||||
"--script", "ps", "--no-git", "--ignore-agent-tools",
|
||||
"--script", "ps", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -342,7 +342,6 @@ class TestHermesInitFlow:
|
||||
result = runner.invoke(app, [
|
||||
"init", str(target),
|
||||
"--integration", "hermes",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
"--script", "sh",
|
||||
])
|
||||
|
||||
@@ -137,7 +137,7 @@ class TestKimiNextSteps:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", "kimi", "--no-git",
|
||||
"init", "--here", "--integration", "kimi",
|
||||
"--ignore-agent-tools", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
|
||||
@@ -140,7 +140,7 @@ class TestKiroIntegration:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", "kiro-cli",
|
||||
"--ignore-agent-tools", "--script", "sh", "--no-git",
|
||||
"--ignore-agent-tools", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
@@ -25,8 +25,7 @@ def _run_init(project, *flags: str) -> Result:
|
||||
os.chdir(project)
|
||||
return CliRunner().invoke(
|
||||
app,
|
||||
["init", "--here", *flags, "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools"],
|
||||
["init", "--here", *flags, "--script", "sh", "--ignore-agent-tools"],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
|
||||
@@ -23,7 +23,6 @@ def _init_project(tmp_path, integration="copilot"):
|
||||
"init", "--here",
|
||||
"--integration", integration,
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -961,7 +960,7 @@ class TestIntegrationSwitch:
|
||||
def test_switch_refreshes_managed_shared_script_refs(self, tmp_path):
|
||||
"""Switching refreshes managed shared scripts to the target command style."""
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
assert shared_script.exists()
|
||||
shared_content = shared_script.read_text(encoding="utf-8")
|
||||
assert "/speckit-plan" in shared_content
|
||||
@@ -987,7 +986,7 @@ class TestIntegrationSwitch:
|
||||
import hashlib
|
||||
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
|
||||
|
||||
# Simulate a stale vendored script: write truncated content as bytes
|
||||
@@ -999,7 +998,7 @@ class TestIntegrationSwitch:
|
||||
|
||||
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
|
||||
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest_data["files"][".specify/scripts/bash/common.sh"] = (
|
||||
manifest_data["files"][".specify/scripts/bash/setup-tasks.sh"] = (
|
||||
hashlib.sha256(stale_bytes).hexdigest()
|
||||
)
|
||||
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
|
||||
@@ -1048,7 +1047,7 @@ class TestIntegrationSwitch:
|
||||
def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path):
|
||||
"""--refresh-shared-infra explicitly overwrites user customizations on switch."""
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
|
||||
rendered_bytes = shared_script.read_bytes()
|
||||
|
||||
|
||||
@@ -254,7 +254,6 @@ class TestMultiInstallSafeContracts:
|
||||
initial,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
|
||||
@@ -1,74 +1,24 @@
|
||||
"""
|
||||
Unit tests for branch numbering options (sequential vs timestamp).
|
||||
Unit tests verifying --branch-numbering removal (v0.10.0).
|
||||
|
||||
Tests cover:
|
||||
- Persisting branch_numbering in init-options.json
|
||||
- Default value when branch_numbering is None
|
||||
- Validation of branch_numbering values
|
||||
Branch numbering is now managed entirely by the git extension's config.
|
||||
The --branch-numbering flag was removed from `specify init`.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from specify_cli import save_init_options
|
||||
|
||||
class TestBranchNumberingFlagRemoved:
|
||||
"""--branch-numbering flag was removed in v0.10.0."""
|
||||
|
||||
class TestSaveBranchNumbering:
|
||||
"""Tests for save_init_options with branch_numbering."""
|
||||
|
||||
def test_save_branch_numbering_timestamp(self, tmp_path: Path):
|
||||
opts = {"branch_numbering": "timestamp", "ai": "claude"}
|
||||
save_init_options(tmp_path, opts)
|
||||
|
||||
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
|
||||
assert saved["branch_numbering"] == "timestamp"
|
||||
|
||||
def test_save_branch_numbering_sequential(self, tmp_path: Path):
|
||||
opts = {"branch_numbering": "sequential", "ai": "claude"}
|
||||
save_init_options(tmp_path, opts)
|
||||
|
||||
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
|
||||
assert saved["branch_numbering"] == "sequential"
|
||||
|
||||
def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project_dir = tmp_path / "proj"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", str(project_dir), "--integration", "claude", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
saved = json.loads((project_dir / ".specify/init-options.json").read_text())
|
||||
assert saved["branch_numbering"] == "sequential"
|
||||
|
||||
|
||||
class TestBranchNumberingValidation:
|
||||
"""Tests for branch_numbering CLI validation via CliRunner."""
|
||||
|
||||
def test_invalid_branch_numbering_rejected(self, tmp_path: Path):
|
||||
def test_branch_numbering_flag_is_rejected(self, tmp_path: Path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "foobar", "--ignore-agent-tools"])
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid --branch-numbering" in result.output
|
||||
|
||||
def test_valid_branch_numbering_sequential(self, tmp_path: Path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||
assert result.exit_code == 0
|
||||
assert "Invalid --branch-numbering" not in (result.output or "")
|
||||
|
||||
def test_valid_branch_numbering_timestamp(self, tmp_path: Path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||
assert result.exit_code == 0
|
||||
assert "Invalid --branch-numbering" not in (result.output or "")
|
||||
result = runner.invoke(app, [
|
||||
"init", str(tmp_path / "proj"), "--integration", "claude",
|
||||
"--branch-numbering", "sequential", "--ignore-agent-tools",
|
||||
])
|
||||
assert result.exit_code != 0, "--branch-numbering should be rejected"
|
||||
assert "No such option" in result.output or "no such option" in result.output.lower()
|
||||
|
||||
@@ -34,6 +34,15 @@ def _install_ps_scripts(repo: Path) -> None:
|
||||
shutil.copy(CHECK_PREREQS_PS, d / "check-prerequisites.ps1")
|
||||
|
||||
|
||||
def _write_feature_json(
|
||||
repo: Path, feature_directory: str = "specs/001-my-feature"
|
||||
) -> None:
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_directory}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
@@ -69,7 +78,10 @@ def prereq_repo(tmp_path: Path) -> Path:
|
||||
|
||||
@requires_bash
|
||||
def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
"""--paths-only must return paths without branch validation (main branch)."""
|
||||
"""--paths-only must return paths when feature.json pins the feature dir."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json", "--paths-only"],
|
||||
@@ -88,20 +100,20 @@ def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
|
||||
@requires_bash
|
||||
def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||
"""--paths-only must also work on a properly named spec branch."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "001-my-feature"],
|
||||
cwd=prereq_repo,
|
||||
check=True,
|
||||
)
|
||||
"""--paths-only must also work when feature.json and SPECIFY_FEATURE agree."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE"] = "001-my-feature"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json", "--paths-only"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
@@ -111,7 +123,10 @@ def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||
|
||||
@requires_bash
|
||||
def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
"""--paths-only without --json must return text paths on a non-spec branch."""
|
||||
"""--paths-only without --json must return text paths from feature.json."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--paths-only"],
|
||||
@@ -128,7 +143,7 @@ def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
|
||||
@requires_bash
|
||||
def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
"""Without --paths-only, branch validation must still fail on main."""
|
||||
"""Without --paths-only, feature directory validation must still fail on main."""
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
@@ -139,7 +154,7 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
assert "Feature directory not found" in result.stderr
|
||||
|
||||
|
||||
# ── PowerShell tests ──────────────────────────────────────────────────────
|
||||
@@ -147,7 +162,10 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
"""-PathsOnly must return paths without branch validation (main branch)."""
|
||||
"""-PathsOnly must return paths when feature.json pins the feature dir."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
result = subprocess.run(
|
||||
@@ -167,21 +185,26 @@ def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||
"""-PathsOnly must also work on a properly named spec branch."""
|
||||
"""-PathsOnly must also work when feature.json and SPECIFY_FEATURE agree."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "001-my-feature"],
|
||||
cwd=prereq_repo,
|
||||
check=True,
|
||||
)
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE"] = "001-my-feature"
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
@@ -190,7 +213,7 @@ def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
"""Without -PathsOnly, branch validation must still fail on main."""
|
||||
"""Without -PathsOnly, feature directory validation must still fail on main."""
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
result = subprocess.run(
|
||||
@@ -202,4 +225,5 @@ def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
combined = result.stdout + result.stderr
|
||||
assert "Feature directory not found" in combined
|
||||
|
||||
@@ -41,6 +41,13 @@ def _minimal_templates(repo: Path) -> None:
|
||||
shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md")
|
||||
|
||||
|
||||
def _write_feature_json(repo: Path, feature_directory: str) -> None:
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_directory}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
"""Return a copy of the current environment with any SPECIFY_* vars removed.
|
||||
|
||||
@@ -89,10 +96,7 @@ def test_setup_plan_passes_custom_branch_when_feature_json_valid(plan_repo: Path
|
||||
feat = plan_repo / "specs" / "001-tiny-notes-app"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(plan_repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": "specs/001-tiny-notes-app"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_write_feature_json(plan_repo, "specs/001-tiny-notes-app")
|
||||
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script)],
|
||||
@@ -107,12 +111,8 @@ def test_setup_plan_passes_custom_branch_when_feature_json_valid(plan_repo: Path
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_plan_fails_custom_branch_without_feature_json(plan_repo: Path) -> None:
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
|
||||
cwd=plan_repo,
|
||||
check=True,
|
||||
)
|
||||
def test_setup_plan_errors_without_feature_context(plan_repo: Path) -> None:
|
||||
"""Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-plan must error."""
|
||||
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script)],
|
||||
@@ -123,13 +123,14 @@ def test_setup_plan_fails_custom_branch_without_feature_json(plan_repo: Path) ->
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
assert "Feature directory not found" in result.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_plan_numbered_branch_unchanged_without_feature_json(
|
||||
def test_setup_plan_numbered_branch_works_with_feature_json(
|
||||
plan_repo: Path,
|
||||
) -> None:
|
||||
"""A numbered branch still works when feature.json explicitly pins the spec dir."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "001-tiny-notes-app"],
|
||||
cwd=plan_repo,
|
||||
@@ -138,6 +139,7 @@ def test_setup_plan_numbered_branch_unchanged_without_feature_json(
|
||||
feat = plan_repo / "specs" / "001-tiny-notes-app"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
_write_feature_json(plan_repo, "specs/001-tiny-notes-app")
|
||||
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script)],
|
||||
@@ -161,10 +163,7 @@ def test_setup_plan_ps_passes_custom_branch_when_feature_json_valid(plan_repo: P
|
||||
feat = plan_repo / "specs" / "001-tiny-notes-app"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(plan_repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": "specs/001-tiny-notes-app"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_write_feature_json(plan_repo, "specs/001-tiny-notes-app")
|
||||
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
result = subprocess.run(
|
||||
@@ -180,14 +179,9 @@ def test_setup_plan_ps_passes_custom_branch_when_feature_json_valid(plan_repo: P
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_plan_ps_fails_custom_branch_without_feature_json(
|
||||
def test_setup_plan_ps_errors_without_feature_context(
|
||||
plan_repo: Path,
|
||||
) -> None:
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
|
||||
cwd=plan_repo,
|
||||
check=True,
|
||||
)
|
||||
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
result = subprocess.run(
|
||||
@@ -198,5 +192,6 @@ def test_setup_plan_ps_fails_custom_branch_without_feature_json(
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
combined = result.stderr + result.stdout
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
assert "Feature directory not found" in combined
|
||||
|
||||
@@ -41,6 +41,15 @@ def _minimal_templates(repo: Path) -> None:
|
||||
shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md")
|
||||
|
||||
|
||||
def _write_feature_json(
|
||||
repo: Path, feature_directory: str = "specs/001-my-feature"
|
||||
) -> None:
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_directory}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
@@ -74,6 +83,7 @@ def plan_repo(tmp_path: Path) -> Path:
|
||||
_minimal_templates(repo)
|
||||
_install_bash_scripts(repo)
|
||||
_install_ps_scripts(repo)
|
||||
_write_feature_json(repo)
|
||||
return repo
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for setup-tasks.{sh,ps1} template resolution and branch validation."""
|
||||
"""Tests for setup-tasks.{sh,ps1} template resolution and feature resolution."""
|
||||
|
||||
import json
|
||||
import os
|
||||
@@ -50,6 +50,15 @@ def _install_core_tasks_template(repo: Path) -> None:
|
||||
shutil.copy(TASKS_TEMPLATE, tdir / "tasks-template.md")
|
||||
|
||||
|
||||
def _write_feature_json(
|
||||
repo: Path, feature_directory: str = "specs/001-my-feature"
|
||||
) -> None:
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_directory}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _minimal_feature(repo: Path) -> Path:
|
||||
"""
|
||||
Create a numbered branch-style feature directory with spec.md and plan.md
|
||||
@@ -60,6 +69,7 @@ def _minimal_feature(repo: Path) -> Path:
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
_write_feature_json(repo)
|
||||
return feat
|
||||
|
||||
|
||||
@@ -85,7 +95,7 @@ def _write_integration_state(repo: Path, integration: str = "claude", separator:
|
||||
def _clean_env() -> dict[str, str]:
|
||||
"""
|
||||
Return os.environ with all SPECIFY_* variables stripped so the scripts
|
||||
rely purely on git branch + feature.json state set up by each fixture.
|
||||
rely purely on feature.json and on-disk feature directories set up by each fixture.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
@@ -153,7 +163,8 @@ def tasks_repo(tmp_path: Path) -> Path:
|
||||
repo.mkdir()
|
||||
_git_init(repo)
|
||||
|
||||
# Switch to a numbered branch so branch validation passes without feature.json
|
||||
# Keep a numbered branch name in this repo fixture; setup-tasks now resolves
|
||||
# feature directories from repository state rather than validating git branches.
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "001-my-feature"],
|
||||
cwd=repo,
|
||||
@@ -492,6 +503,7 @@ def test_setup_tasks_bash_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -
|
||||
feat = tasks_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
_write_feature_json(tasks_repo)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
@@ -550,11 +562,7 @@ def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
(tasks_repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": "specs/001-my-feature"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_write_feature_json(tasks_repo)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
@@ -571,21 +579,17 @@ def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_fails_custom_branch_without_feature_json(
|
||||
def test_setup_tasks_bash_errors_without_feature_context(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
"""
|
||||
On a non-standard branch with no feature.json, setup-tasks.sh must fail
|
||||
and report that we are not on a feature branch.
|
||||
"""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "feature/custom-branch"],
|
||||
cwd=tasks_repo,
|
||||
check=True,
|
||||
)
|
||||
|
||||
"""Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-tasks.sh must error."""
|
||||
main_feat = tasks_repo / "specs" / "main"
|
||||
main_feat.mkdir(parents=True, exist_ok=True)
|
||||
(main_feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(main_feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=tasks_repo,
|
||||
@@ -596,7 +600,7 @@ def test_setup_tasks_bash_fails_custom_branch_without_feature_json(
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
assert "Feature directory not found" in result.stderr
|
||||
|
||||
# ===========================================================================
|
||||
# POWERSHELL TESTS
|
||||
@@ -731,6 +735,7 @@ def test_setup_tasks_ps_uses_invoke_separator_in_plan_hint(tasks_repo: Path) ->
|
||||
feat = tasks_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
_write_feature_json(tasks_repo)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
@@ -793,11 +798,7 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
(tasks_repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": "specs/001-my-feature"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_write_feature_json(tasks_repo)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
@@ -815,22 +816,18 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_fails_custom_branch_without_feature_json(
|
||||
def test_setup_tasks_ps_errors_without_feature_context(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
"""
|
||||
On a non-standard branch with no feature.json, setup-tasks.ps1 must fail
|
||||
and report that we are not on a feature branch.
|
||||
"""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "feature/custom-branch"],
|
||||
cwd=tasks_repo,
|
||||
check=True,
|
||||
)
|
||||
|
||||
"""Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-tasks.ps1 must error."""
|
||||
main_feat = tasks_repo / "specs" / "main"
|
||||
main_feat.mkdir(parents=True, exist_ok=True)
|
||||
(main_feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(main_feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=tasks_repo,
|
||||
@@ -839,6 +836,7 @@ def test_setup_tasks_ps_fails_custom_branch_without_feature_json(
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
output = result.stderr + result.stdout
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
assert "Feature directory not found" in output
|
||||
|
||||
@@ -19,14 +19,12 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh"
|
||||
CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||
EXT_CREATE_FEATURE = (
|
||||
PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
|
||||
PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature-branch.sh"
|
||||
)
|
||||
EXT_CREATE_FEATURE_PS = (
|
||||
PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||
PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
|
||||
)
|
||||
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
EXT_CREATE_FEATURE = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
|
||||
EXT_CREATE_FEATURE_PS = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
|
||||
@@ -77,7 +75,7 @@ def ext_git_repo(tmp_path: Path) -> Path:
|
||||
# Copy extension script
|
||||
ext_dir = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash"
|
||||
ext_dir.mkdir(parents=True)
|
||||
shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature.sh")
|
||||
shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature-branch.sh")
|
||||
# Also copy git-common.sh if it exists
|
||||
git_common = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
|
||||
if git_common.exists():
|
||||
@@ -106,7 +104,7 @@ def ext_ps_git_repo(tmp_path: Path) -> Path:
|
||||
# Copy extension script
|
||||
ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell"
|
||||
ext_ps.mkdir(parents=True)
|
||||
shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature.ps1")
|
||||
shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature-branch.ps1")
|
||||
git_common_ps = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
|
||||
if git_common_ps.exists():
|
||||
shutil.copy(git_common_ps, ext_ps / "git-common.ps1")
|
||||
@@ -279,64 +277,13 @@ class TestSequentialBranchPowerShell:
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestCheckFeatureBranch:
|
||||
def test_accepts_timestamp_branch(self):
|
||||
"""Test 6: check_feature_branch accepts timestamp branch."""
|
||||
result = source_and_call('check_feature_branch "20260319-143022-feat" "true"')
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_accepts_sequential_branch(self):
|
||||
"""Test 7: check_feature_branch accepts sequential branch."""
|
||||
result = source_and_call('check_feature_branch "004-feat" "true"')
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_rejects_main(self):
|
||||
"""Test 8: check_feature_branch rejects main."""
|
||||
result = source_and_call('check_feature_branch "main" "true"')
|
||||
class TestCoreCommonRemovesGitHelpers:
|
||||
def test_check_feature_branch_removed(self):
|
||||
result = source_and_call('declare -F check_feature_branch >/dev/null')
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_accepts_four_digit_sequential_branch(self):
|
||||
"""check_feature_branch accepts 4+ digit sequential branch."""
|
||||
result = source_and_call('check_feature_branch "1234-feat" "true"')
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_rejects_partial_timestamp(self):
|
||||
"""Test 9: check_feature_branch rejects 7-digit date."""
|
||||
result = source_and_call('check_feature_branch "2026031-143022-feat" "true"')
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_rejects_timestamp_without_slug(self):
|
||||
"""check_feature_branch rejects timestamp-like branch missing trailing slug."""
|
||||
result = source_and_call('check_feature_branch "20260319-143022" "true"')
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_rejects_7digit_timestamp_without_slug(self):
|
||||
"""check_feature_branch rejects 7-digit date + 6-digit time without slug."""
|
||||
result = source_and_call('check_feature_branch "2026031-143022" "true"')
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_accepts_single_prefix_sequential(self):
|
||||
"""Optional gitflow-style prefix: one segment + sequential feature name."""
|
||||
result = source_and_call('check_feature_branch "feat/004-my-feature" "true"')
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_accepts_single_prefix_timestamp(self):
|
||||
"""Optional prefix + timestamp-style feature name."""
|
||||
result = source_and_call('check_feature_branch "release/20260319-143022-feat" "true"')
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_rejects_invalid_suffix_with_single_prefix(self):
|
||||
result = source_and_call('check_feature_branch "feat/main" "true"')
|
||||
assert result.returncode != 0
|
||||
assert "feat/main" in result.stderr
|
||||
|
||||
def test_rejects_two_level_prefix_before_feature(self):
|
||||
"""More than one slash: no stripping; whole name must match (fails)."""
|
||||
result = source_and_call('check_feature_branch "feat/fix/004-feat" "true"')
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_rejects_malformed_timestamp_with_prefix(self):
|
||||
result = source_and_call('check_feature_branch "feat/2026031-143022-feat" "true"')
|
||||
def test_has_git_removed(self):
|
||||
result = source_and_call('declare -F has_git >/dev/null')
|
||||
assert result.returncode != 0
|
||||
|
||||
|
||||
@@ -344,50 +291,11 @@ class TestCheckFeatureBranch:
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestFindFeatureDirByPrefix:
|
||||
def test_timestamp_branch(self, tmp_path: Path):
|
||||
"""Test 10: find_feature_dir_by_prefix with timestamp branch."""
|
||||
(tmp_path / "specs" / "20260319-143022-user-auth").mkdir(parents=True)
|
||||
result = source_and_call(
|
||||
f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-user-auth"'
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-user-auth"
|
||||
|
||||
def test_cross_branch_prefix(self, tmp_path: Path):
|
||||
"""Test 11: find_feature_dir_by_prefix cross-branch (different suffix, same timestamp)."""
|
||||
(tmp_path / "specs" / "20260319-143022-original-feat").mkdir(parents=True)
|
||||
result = source_and_call(
|
||||
f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-different-name"'
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-original-feat"
|
||||
|
||||
def test_four_digit_sequential_prefix(self, tmp_path: Path):
|
||||
"""find_feature_dir_by_prefix resolves 4+ digit sequential prefix."""
|
||||
(tmp_path / "specs" / "1000-original-feat").mkdir(parents=True)
|
||||
result = source_and_call(
|
||||
f'find_feature_dir_by_prefix "{tmp_path}" "1000-different-name"'
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == f"{tmp_path}/specs/1000-original-feat"
|
||||
|
||||
def test_sequential_with_single_path_prefix(self, tmp_path: Path):
|
||||
"""Strip one optional prefix segment before prefix directory lookup."""
|
||||
(tmp_path / "specs" / "004-only-dir").mkdir(parents=True)
|
||||
result = source_and_call(
|
||||
f'find_feature_dir_by_prefix "{tmp_path}" "feat/004-other-suffix"'
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == f"{tmp_path}/specs/004-only-dir"
|
||||
|
||||
def test_timestamp_with_single_path_prefix_cross_branch(self, tmp_path: Path):
|
||||
(tmp_path / "specs" / "20260319-143022-canonical").mkdir(parents=True)
|
||||
result = source_and_call(
|
||||
f'find_feature_dir_by_prefix "{tmp_path}" "hotfix/20260319-143022-alias"'
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-canonical"
|
||||
class TestFindFeatureDirByPrefixRemoved:
|
||||
def test_find_feature_dir_by_prefix_removed(self):
|
||||
"""Directory scanning helper is removed from core common.sh."""
|
||||
result = source_and_call('declare -F find_feature_dir_by_prefix >/dev/null')
|
||||
assert result.returncode != 0
|
||||
|
||||
|
||||
# ── get_feature_paths + single-prefix integration ───────────────────────────
|
||||
@@ -395,26 +303,29 @@ class TestFindFeatureDirByPrefix:
|
||||
|
||||
class TestGetFeaturePathsSinglePrefix:
|
||||
@requires_bash
|
||||
def test_bash_specify_feature_prefixed_resolves_by_prefix(self, tmp_path: Path):
|
||||
"""get_feature_paths: SPECIFY_FEATURE with one optional prefix uses effective name for lookup."""
|
||||
def test_bash_specify_feature_prefixed_requires_explicit_feature_context(
|
||||
self, tmp_path: Path
|
||||
):
|
||||
"""SPECIFY_FEATURE alone no longer triggers path lookup in bash."""
|
||||
(tmp_path / ".specify").mkdir()
|
||||
(tmp_path / "specs" / "001-target-spec").mkdir(parents=True)
|
||||
cmd = (
|
||||
f'cd "{tmp_path}" && export SPECIFY_FEATURE="feat/001-other" && '
|
||||
f'source "{COMMON_SH}" && eval "$(get_feature_paths)" && printf "%s" "$FEATURE_DIR"'
|
||||
f'source "{COMMON_SH}" && get_feature_paths'
|
||||
)
|
||||
result = subprocess.run(
|
||||
["bash", "-c", cmd],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == str(tmp_path / "specs" / "001-target-spec")
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Feature directory not found" in result.stderr
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path):
|
||||
"""PowerShell Get-FeaturePathsEnv: same prefix stripping as bash."""
|
||||
def test_ps_specify_feature_prefixed_requires_explicit_feature_context(
|
||||
self, git_repo: Path
|
||||
):
|
||||
"""PowerShell also requires feature.json or SPECIFY_FEATURE_DIRECTORY."""
|
||||
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
spec_dir = git_repo / "specs" / "001-ps-prefix-spec"
|
||||
spec_dir.mkdir(parents=True)
|
||||
@@ -426,14 +337,8 @@ class TestGetFeaturePathsSinglePrefix:
|
||||
text=True,
|
||||
env={**os.environ, "SPECIFY_FEATURE": "feat/001-other"},
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("FEATURE_DIR="):
|
||||
val = line.split("=", 1)[1].strip()
|
||||
assert val == str(spec_dir)
|
||||
break
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in PowerShell output")
|
||||
assert result.returncode != 0
|
||||
assert "Feature directory not found" in (result.stderr + result.stdout)
|
||||
|
||||
|
||||
# ── get_current_branch Tests ─────────────────────────────────────────────────
|
||||
@@ -453,12 +358,11 @@ class TestGetCurrentBranch:
|
||||
@requires_bash
|
||||
class TestNoGitTimestamp:
|
||||
def test_no_git_timestamp(self, no_git_dir: Path):
|
||||
"""Test 13: No-git repo + timestamp creates spec dir with warning."""
|
||||
"""Test 13: Timestamp mode works without git and creates a spec dir."""
|
||||
result = run_script(no_git_dir, "--timestamp", "--short-name", "no-git-feat", "No git feature")
|
||||
assert result.returncode == 0, result.stderr
|
||||
spec_dirs = list((no_git_dir / "specs").iterdir()) if (no_git_dir / "specs").exists() else []
|
||||
assert len(spec_dirs) > 0, "spec dir not created"
|
||||
assert "git" in result.stderr.lower() or "warning" in result.stderr.lower()
|
||||
|
||||
|
||||
# ── E2E Flow Tests ───────────────────────────────────────────────────────────
|
||||
@@ -467,32 +371,65 @@ class TestNoGitTimestamp:
|
||||
@requires_bash
|
||||
class TestE2EFlow:
|
||||
def test_e2e_timestamp(self, git_repo: Path):
|
||||
"""Test 14: E2E timestamp flow — branch, dir, validation."""
|
||||
run_script(git_repo, "--timestamp", "--short-name", "e2e-ts", "E2E timestamp test")
|
||||
branch = subprocess.run(
|
||||
"""Test 14: E2E timestamp flow creates only a feature directory."""
|
||||
before = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
assert re.match(r"^\d{8}-\d{6}-e2e-ts$", branch), f"branch: {branch}"
|
||||
assert (git_repo / "specs" / branch).is_dir()
|
||||
val = source_and_call(f'check_feature_branch "{branch}" "true"')
|
||||
assert val.returncode == 0
|
||||
result = run_script(git_repo, "--timestamp", "--short-name", "e2e-ts", "E2E timestamp test")
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
branch_name = None
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("BRANCH_NAME:"):
|
||||
branch_name = line.split(":", 1)[1].strip()
|
||||
break
|
||||
|
||||
assert branch_name is not None
|
||||
assert re.match(r"^\d{8}-\d{6}-e2e-ts$", branch_name), f"branch: {branch_name}"
|
||||
assert (git_repo / "specs" / branch_name).is_dir()
|
||||
|
||||
after = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
assert after == before
|
||||
|
||||
def test_e2e_sequential(self, git_repo: Path):
|
||||
"""Test 15: E2E sequential flow (regression guard)."""
|
||||
run_script(git_repo, "--short-name", "seq-feat", "Sequential feature")
|
||||
branch = subprocess.run(
|
||||
"""Test 15: E2E sequential flow creates only a feature directory."""
|
||||
before = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
assert re.match(r"^\d{3,}-seq-feat$", branch), f"branch: {branch}"
|
||||
assert (git_repo / "specs" / branch).is_dir()
|
||||
val = source_and_call(f'check_feature_branch "{branch}" "true"')
|
||||
assert val.returncode == 0
|
||||
result = run_script(git_repo, "--short-name", "seq-feat", "Sequential feature")
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
branch_name = None
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("BRANCH_NAME:"):
|
||||
branch_name = line.split(":", 1)[1].strip()
|
||||
break
|
||||
|
||||
assert branch_name == "001-seq-feat"
|
||||
assert (git_repo / "specs" / branch_name).is_dir()
|
||||
|
||||
after = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
assert after == before
|
||||
|
||||
|
||||
# ── Allow Existing Branch Tests ──────────────────────────────────────────────
|
||||
@@ -500,67 +437,22 @@ class TestE2EFlow:
|
||||
|
||||
@requires_bash
|
||||
class TestAllowExistingBranch:
|
||||
def test_allow_existing_switches_to_branch(self, git_repo: Path):
|
||||
"""T006: Pre-create branch, verify script switches to it."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "004-pre-exist"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "checkout", "-"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
def test_allow_existing_reuses_existing_feature_dir(self, git_repo: Path):
|
||||
"""T006: Existing feature directory can be reused when the flag is set."""
|
||||
feature_dir = git_repo / "specs" / "004-pre-exist"
|
||||
feature_dir.mkdir(parents=True)
|
||||
|
||||
result = run_script(
|
||||
git_repo, "--allow-existing-branch", "--short-name", "pre-exist",
|
||||
"--number", "4", "Pre-existing feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
current = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo, capture_output=True, text=True,
|
||||
).stdout.strip()
|
||||
assert current == "004-pre-exist", f"expected 004-pre-exist, got {current}"
|
||||
|
||||
def test_allow_existing_already_on_branch(self, git_repo: Path):
|
||||
"""T007: Verify success when already on the target branch."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "005-already-on"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
result = run_script(
|
||||
git_repo, "--allow-existing-branch", "--short-name", "already-on",
|
||||
"--number", "5", "Already on branch",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
def test_allow_existing_creates_spec_dir(self, git_repo: Path):
|
||||
"""T008: Verify spec directory created on existing branch."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "006-spec-dir"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "checkout", "-"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
result = run_script(
|
||||
git_repo, "--allow-existing-branch", "--short-name", "spec-dir",
|
||||
"--number", "6", "Spec dir feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert (git_repo / "specs" / "006-spec-dir").is_dir()
|
||||
assert (git_repo / "specs" / "006-spec-dir" / "spec.md").exists()
|
||||
assert feature_dir.is_dir()
|
||||
assert (feature_dir / "spec.md").exists()
|
||||
|
||||
def test_without_flag_still_errors(self, git_repo: Path):
|
||||
"""T009: Verify backwards compatibility (error without flag)."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "007-no-flag"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "checkout", "-"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
"""T009: Existing feature directories still fail without the flag."""
|
||||
(git_repo / "specs" / "007-no-flag").mkdir(parents=True)
|
||||
result = run_script(
|
||||
git_repo, "--short-name", "no-flag", "--number", "7", "No flag feature",
|
||||
)
|
||||
@@ -569,18 +461,11 @@ class TestAllowExistingBranch:
|
||||
|
||||
def test_allow_existing_no_overwrite_spec(self, git_repo: Path):
|
||||
"""T010: Pre-create spec.md with content, verify it is preserved."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "008-no-overwrite"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
spec_dir = git_repo / "specs" / "008-no-overwrite"
|
||||
spec_dir.mkdir(parents=True)
|
||||
spec_file = spec_dir / "spec.md"
|
||||
spec_file.write_text("# My custom spec content\n")
|
||||
subprocess.run(
|
||||
["git", "checkout", "-"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
|
||||
result = run_script(
|
||||
git_repo, "--allow-existing-branch", "--short-name", "no-overwrite",
|
||||
"--number", "8", "No overwrite feature",
|
||||
@@ -588,31 +473,20 @@ class TestAllowExistingBranch:
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert spec_file.read_text() == "# My custom spec content\n"
|
||||
|
||||
def test_allow_existing_creates_branch_if_not_exists(self, git_repo: Path):
|
||||
"""T011: Verify normal creation when branch doesn't exist."""
|
||||
def test_allow_existing_creates_feature_dir_when_missing(self, git_repo: Path):
|
||||
"""T011: Verify normal directory creation when the feature dir does not exist."""
|
||||
result = run_script(
|
||||
git_repo, "--allow-existing-branch", "--short-name", "new-branch",
|
||||
"New branch feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
current = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo, capture_output=True, text=True,
|
||||
).stdout.strip()
|
||||
assert "new-branch" in current
|
||||
assert (git_repo / "specs" / "001-new-branch").is_dir()
|
||||
|
||||
def test_allow_existing_with_json(self, git_repo: Path):
|
||||
"""T012: Verify JSON output is correct."""
|
||||
import json
|
||||
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "009-json-test"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "checkout", "-"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
(git_repo / "specs" / "009-json-test").mkdir(parents=True)
|
||||
result = run_script(
|
||||
git_repo, "--allow-existing-branch", "--json", "--short-name", "json-test",
|
||||
"--number", "9", "JSON test",
|
||||
@@ -622,64 +496,26 @@ class TestAllowExistingBranch:
|
||||
assert data["BRANCH_NAME"] == "009-json-test"
|
||||
|
||||
def test_allow_existing_no_git(self, no_git_dir: Path):
|
||||
"""T013: Verify flag is silently ignored in non-git repos."""
|
||||
"""T013: Verify flag also works in non-git repos."""
|
||||
result = run_script(
|
||||
no_git_dir, "--allow-existing-branch", "--short-name", "no-git",
|
||||
"No git feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
def test_allow_existing_surfaces_checkout_error(self, git_repo: Path):
|
||||
"""Checkout failures on an existing branch should include Git's stderr."""
|
||||
shared_file = git_repo / "shared.txt"
|
||||
shared_file.write_text("base\n")
|
||||
subprocess.run(
|
||||
["git", "add", "shared.txt"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "add shared file", "-q"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "010-checkout-failure"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
shared_file.write_text("branch version\n")
|
||||
subprocess.run(
|
||||
["git", "commit", "-am", "branch change", "-q"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "checkout", "-"],
|
||||
cwd=git_repo, check=True, capture_output=True,
|
||||
)
|
||||
shared_file.write_text("uncommitted main change\n")
|
||||
|
||||
result = run_script(
|
||||
git_repo, "--allow-existing-branch", "--short-name", "checkout-failure",
|
||||
"--number", "10", "Checkout failure",
|
||||
)
|
||||
|
||||
assert result.returncode != 0, "checkout should fail with conflicting local changes"
|
||||
assert "Failed to switch to existing branch '010-checkout-failure'" in result.stderr
|
||||
assert "would be overwritten by checkout" in result.stderr
|
||||
assert "shared.txt" in result.stderr
|
||||
|
||||
|
||||
class TestAllowExistingBranchPowerShell:
|
||||
def test_powershell_supports_allow_existing_branch_flag(self):
|
||||
"""Static guard: PS script exposes and uses -AllowExistingBranch."""
|
||||
contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
|
||||
assert "-AllowExistingBranch" in contents
|
||||
# Ensure the flag is referenced in script logic, not just declared
|
||||
assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "")
|
||||
|
||||
def test_powershell_surfaces_checkout_errors(self):
|
||||
"""Static guard: PS script preserves checkout stderr on existing-branch failures."""
|
||||
def test_powershell_reuses_existing_feature_dir(self):
|
||||
"""Static guard: PS script handles existing feature directories without git."""
|
||||
contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
|
||||
assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents
|
||||
assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents
|
||||
assert "Feature directory '$featureDir' already exists" in contents
|
||||
assert "-not $AllowExistingBranch" in contents
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
@pytest.mark.skipif(
|
||||
@@ -754,20 +590,27 @@ class TestDryRun:
|
||||
branch = line.split(":", 1)[1].strip()
|
||||
assert branch == "003-new-feat", f"expected 003-new-feat, got: {branch}"
|
||||
|
||||
def test_dry_run_no_branch_created(self, git_repo: Path):
|
||||
"""T010: Dry-run does not create a git branch."""
|
||||
def test_dry_run_does_not_change_git_branch(self, git_repo: Path):
|
||||
"""T010: Dry-run leaves the current git branch untouched."""
|
||||
before = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
result = run_script(
|
||||
git_repo, "--dry-run", "--short-name", "no-branch", "No branch feature"
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
branches = subprocess.run(
|
||||
["git", "branch", "--list", "*no-branch*"],
|
||||
after = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
|
||||
assert branches.stdout.strip() == "", "branch should not exist after dry-run"
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
assert after == before
|
||||
|
||||
def test_dry_run_no_spec_dir_created(self, git_repo: Path):
|
||||
"""T011: Dry-run does not create any directories (including root specs/)."""
|
||||
@@ -832,50 +675,22 @@ class TestDryRun:
|
||||
real_branch = line.split(":", 1)[1].strip()
|
||||
assert dry_branch == real_branch, f"dry={dry_branch} != real={real_branch}"
|
||||
|
||||
def test_dry_run_accounts_for_remote_branches(self, git_repo: Path):
|
||||
"""Dry-run queries remote refs via ls-remote (no fetch) for accurate numbering."""
|
||||
def test_dry_run_ignores_git_branches(self, git_repo: Path):
|
||||
"""Dry-run uses only spec directories for numbering."""
|
||||
(git_repo / "specs" / "001-existing").mkdir(parents=True)
|
||||
|
||||
# Set up a bare remote and push (use subdirs of git_repo for isolation)
|
||||
remote_dir = git_repo / "test-remote.git"
|
||||
subprocess.run(
|
||||
["git", "init", "--bare", str(remote_dir)],
|
||||
check=True, capture_output=True,
|
||||
["git", "checkout", "-b", "005-git-only"],
|
||||
cwd=git_repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "remote", "add", "origin", str(remote_dir)],
|
||||
check=True, cwd=git_repo, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "push", "-u", "origin", "HEAD"],
|
||||
check=True, cwd=git_repo, capture_output=True,
|
||||
["git", "checkout", "-"],
|
||||
cwd=git_repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Clone into a second copy, create a higher-numbered branch, push it
|
||||
second_clone = git_repo / "test-second-clone"
|
||||
subprocess.run(
|
||||
["git", "clone", str(remote_dir), str(second_clone)],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"],
|
||||
cwd=second_clone, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test User"],
|
||||
cwd=second_clone, check=True, capture_output=True,
|
||||
)
|
||||
# Create branch 005 on the remote (higher than local 001)
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "005-remote-only"],
|
||||
cwd=second_clone, check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "push", "origin", "005-remote-only"],
|
||||
cwd=second_clone, check=True, capture_output=True,
|
||||
)
|
||||
|
||||
# Primary repo: dry-run should see 005 via ls-remote and return 006
|
||||
dry_result = run_script(
|
||||
git_repo, "--dry-run", "--short-name", "remote-test", "Remote test"
|
||||
)
|
||||
@@ -884,7 +699,7 @@ class TestDryRun:
|
||||
for line in dry_result.stdout.splitlines():
|
||||
if line.startswith("BRANCH_NAME:"):
|
||||
dry_branch = line.split(":", 1)[1].strip()
|
||||
assert dry_branch == "006-remote-test", f"expected 006-remote-test, got: {dry_branch}"
|
||||
assert dry_branch == "002-remote-test", f"expected 002-remote-test, got: {dry_branch}"
|
||||
|
||||
def test_dry_run_json_includes_field(self, git_repo: Path):
|
||||
"""T015: JSON output includes DRY_RUN field when --dry-run is active."""
|
||||
@@ -910,7 +725,14 @@ class TestDryRun:
|
||||
assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}"
|
||||
|
||||
def test_dry_run_with_timestamp(self, git_repo: Path):
|
||||
"""T017: Dry-run works with --timestamp flag."""
|
||||
"""T017: Dry-run works with --timestamp flag without mutating git state."""
|
||||
before = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
result = run_script(
|
||||
git_repo, "--dry-run", "--timestamp", "--short-name", "ts-feat", "Timestamp feature"
|
||||
)
|
||||
@@ -921,15 +743,14 @@ class TestDryRun:
|
||||
branch = line.split(":", 1)[1].strip()
|
||||
assert branch is not None, "no BRANCH_NAME in output"
|
||||
assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}"
|
||||
# Verify no side effects
|
||||
branches = subprocess.run(
|
||||
["git", "branch", "--list", "*ts-feat*"],
|
||||
after = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
|
||||
assert branches.stdout.strip() == ""
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
assert after == before
|
||||
|
||||
def test_dry_run_with_number(self, git_repo: Path):
|
||||
"""T018: Dry-run works with --number flag."""
|
||||
@@ -989,20 +810,27 @@ class TestPowerShellDryRun:
|
||||
branch = line.split(":", 1)[1].strip()
|
||||
assert branch == "002-ps-feat", f"expected 002-ps-feat, got: {branch}"
|
||||
|
||||
def test_ps_dry_run_no_branch_created(self, ps_git_repo: Path):
|
||||
"""PowerShell -DryRun does not create a git branch."""
|
||||
def test_ps_dry_run_does_not_change_git_branch(self, ps_git_repo: Path):
|
||||
"""PowerShell -DryRun leaves the current git branch untouched."""
|
||||
before = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=ps_git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
result = run_ps_script(
|
||||
ps_git_repo, "-DryRun", "-ShortName", "no-ps-branch", "No branch"
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
branches = subprocess.run(
|
||||
["git", "branch", "--list", "*no-ps-branch*"],
|
||||
after = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=ps_git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
|
||||
assert branches.stdout.strip() == "", "branch should not exist after dry-run"
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
assert after == before
|
||||
|
||||
def test_ps_dry_run_no_spec_dir_created(self, ps_git_repo: Path):
|
||||
"""PowerShell -DryRun does not create specs/ directory."""
|
||||
@@ -1046,10 +874,10 @@ class TestPowerShellDryRun:
|
||||
|
||||
@requires_bash
|
||||
class TestGitBranchNameOverrideBash:
|
||||
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh."""
|
||||
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature-branch.sh."""
|
||||
|
||||
def _run_ext(self, ext_git_repo: Path, env_extras: dict, *extra_args: str):
|
||||
script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
|
||||
script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature-branch.sh"
|
||||
cmd = ["bash", str(script), "--json", *extra_args, "ignored"]
|
||||
return subprocess.run(cmd, cwd=ext_git_repo, capture_output=True, text=True,
|
||||
env={**os.environ, **env_extras})
|
||||
@@ -1101,10 +929,10 @@ class TestGitBranchNameOverrideBash:
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
class TestGitBranchNameOverridePowerShell:
|
||||
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.ps1."""
|
||||
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature-branch.ps1."""
|
||||
|
||||
def _run_ext(self, ext_ps_git_repo: Path, env_extras: dict):
|
||||
script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||
script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
|
||||
return subprocess.run(
|
||||
["pwsh", "-NoProfile", "-File", str(script), "-Json", "ignored"],
|
||||
cwd=ext_ps_git_repo, capture_output=True, text=True,
|
||||
@@ -1230,9 +1058,8 @@ class TestFeatureDirectoryResolution:
|
||||
pytest.fail("FEATURE_DIR not found in output")
|
||||
|
||||
@requires_bash
|
||||
def test_fallback_to_branch_lookup(self, git_repo: Path):
|
||||
"""Without env var or feature.json, falls back to branch-based lookup."""
|
||||
subprocess.run(["git", "checkout", "-q", "-b", "001-test-feat"], cwd=git_repo, check=True)
|
||||
def test_errors_without_env_var_or_feature_json(self, git_repo: Path):
|
||||
"""Without env var or feature.json, get_feature_paths now errors."""
|
||||
spec_dir = git_repo / "specs" / "001-test-feat"
|
||||
spec_dir.mkdir(parents=True)
|
||||
|
||||
@@ -1242,14 +1069,8 @@ class TestFeatureDirectoryResolution:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("FEATURE_DIR="):
|
||||
val = line.split("=", 1)[1].strip("'\"")
|
||||
assert val == str(spec_dir)
|
||||
break
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in output")
|
||||
assert result.returncode != 0
|
||||
assert "Feature directory not found" in result.stderr
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
def test_ps_env_var_overrides_branch_lookup(self, git_repo: Path):
|
||||
@@ -1343,7 +1164,7 @@ class TestDescriptionQuoting:
|
||||
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
|
||||
)
|
||||
def test_ext_script_handles_special_chars(self, ext_git_repo: Path, description: str):
|
||||
"""Extension create-new-feature.sh succeeds with special characters in description."""
|
||||
"""Extension create-new-feature-branch.sh succeeds with special characters in description."""
|
||||
script = (
|
||||
ext_git_repo
|
||||
/ ".specify"
|
||||
@@ -1351,7 +1172,7 @@ class TestDescriptionQuoting:
|
||||
/ "git"
|
||||
/ "scripts"
|
||||
/ "bash"
|
||||
/ "create-new-feature.sh"
|
||||
/ "create-new-feature-branch.sh"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--dry-run", "--short-name", "feat", description],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Regression guard: utility and asset symbols importable from specify_cli."""
|
||||
from specify_cli import (
|
||||
check_tool, is_git_repo, merge_json_files,
|
||||
check_tool, merge_json_files,
|
||||
get_speckit_version,
|
||||
CLAUDE_LOCAL_PATH, CLAUDE_NPM_LOCAL_PATH,
|
||||
)
|
||||
@@ -9,7 +9,6 @@ from pathlib import Path
|
||||
def test_utils_symbols_importable():
|
||||
assert callable(check_tool)
|
||||
assert callable(merge_json_files)
|
||||
assert callable(is_git_repo)
|
||||
|
||||
def test_get_speckit_version_returns_string():
|
||||
version = get_speckit_version()
|
||||
|
||||
Reference in New Issue
Block a user