diff --git a/docs/installation.md b/docs/installation.md index 99b37f0d9f..3ee2f67b0e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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 diff --git a/docs/local-development.md b/docs/local-development.md index 4776204d7d..7cfda4e9dd 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -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 step skipped | Git not installed on your system | | 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. | diff --git a/docs/reference/core.md b/docs/reference/core.md index 70c711b1cc..c5d9d1b4ec 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -15,16 +15,13 @@ specify init [] | `--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 ` | 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 `` 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. diff --git a/docs/upgrade.md b/docs/upgrade.md index 820cc9eabf..e084b6c27a 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -257,58 +257,28 @@ 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 -``` - -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:** - -```bash -specify init my-project --integration copilot --no-git +mv .specify/memory/constitution.backup.md .specify/memory/constitution.md ``` -**During upgrade:** +If you later decide you want the git extension's commands and hooks, install it explicitly: ```bash -specify init --here --force --integration copilot --no-git +specify extension add 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` manually before planning commands: ```bash # Bash/Zsh @@ -318,10 +288,6 @@ export SPECIFY_FEATURE="001-my-feature" $env:SPECIFY_FEATURE = "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. - --- ## Troubleshooting diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md index 5bed9e5e57..3e56e1016f 100644 --- a/extensions/git/commands/speckit.git.feature.md +++ b/extensions/git/commands/speckit.git.feature.md @@ -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 diff --git a/scripts/bash/check-prerequisites.sh b/scripts/bash/check-prerequisites.sh index 95e7344d80..2c1b8e1351 100644 --- a/scripts/bash/check-prerequisites.sh +++ b/scripts/bash/check-prerequisites.sh @@ -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 diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 9d7dd21edf..6829da51df 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -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 errors if none is set. +# Feature state is set by the git extension (via SPECIFY_FEATURE) or by +# the specify command (via .specify/feature.json read in get_feature_paths). 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. @@ -186,7 +87,7 @@ read_feature_json_feature_directory() { } # 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). +# and matches the resolved active FEATURE_DIR. # Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`. feature_json_matches_feature_dir() { local repo_root="$1" @@ -206,84 +107,32 @@ feature_json_matches_feature_dir() { [[ "$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 - 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 - fi - - # Handle results - if [[ ${#matches[@]} -eq 0 ]]; then - # No match found - return the branch name path (will fail later with clear error) - echo "$specs_dir/$branch_name" - elif [[ ${#matches[@]} -eq 1 ]]; then - # Exactly one match - perfect! - echo "$specs_dir/${matches[0]}" - else - # Multiple matches - this shouldn't happen with proper naming convention - echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2 - echo "Please ensure only one spec directory exists per prefix." >&2 - return 1 - 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" 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 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 or run the specify command to create .specify/feature.json." >&2 return 1 fi @@ -291,7 +140,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" diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index c3537704f6..7675f7e1ea 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -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 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 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" diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index 945385c643..cb679437a9 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -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 diff --git a/scripts/bash/setup-tasks.sh b/scripts/bash/setup-tasks.sh index 73bc095b48..1eb21a8cd6 100644 --- a/scripts/bash/setup-tasks.sh +++ b/scripts/bash/setup-tasks.sh @@ -28,11 +28,6 @@ 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 - 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 diff --git a/scripts/powershell/check-prerequisites.ps1 b/scripts/powershell/check-prerequisites.ps1 index bcf3aa46c4..bb60e52c85 100644 --- a/scripts/powershell/check-prerequisites.ps1 +++ b/scripts/powershell/check-prerequisites.ps1 @@ -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)" diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 42ffdf1390..39f16b55d5 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -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,139 +33,25 @@ 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 the git extension (via SPECIFY_FEATURE) or by + # the specify command (via .specify/feature.json read in Get-FeaturePathsEnv). 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) { - try { - $result = git -C $repoRoot rev-parse --abbrev-ref HEAD 2>$null - if ($LASTEXITCODE -eq 0) { - return $result - } - } catch { - # Git command failed - } - } - - # 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 - } - - $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 + # No explicit feature set - return empty to signal "unknown". + return "" } # 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). +# active FEATURE_DIR from Get-FeaturePathsEnv. function Test-FeatureJsonMatchesFeatureDir { param( [Parameter(Mandatory = $true)][string]$RepoRoot, @@ -232,64 +118,14 @@ function Test-FeatureJsonMatchesFeatureDir { return [string]::Equals($normJson, $normActive, $comparison) } -# Resolve specs/ 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 -} - 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 @@ -312,16 +148,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 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 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' diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 19cd1b02c0..9eb835a552 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -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 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 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 (-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 - } + 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." } - } 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 @@ -368,7 +217,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,7 +226,6 @@ 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" } diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index d9eecdf75c..e34de0fba8 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -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)" } diff --git a/scripts/powershell/setup-tasks.ps1 b/scripts/powershell/setup-tasks.ps1 index 41de629685..c7d85fc2a6 100644 --- a/scripts/powershell/setup-tasks.ps1 +++ b/scripts/powershell/setup-tasks.ps1 @@ -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 diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 23d31cb0cf..9052efde13 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -69,8 +69,6 @@ _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]") diff --git a/src/specify_cli/_utils.py b/src/specify_cli/_utils.py index beae253593..6daab08316 100644 --- a/src/specify_cli/_utils.py +++ b/src/specify_cli/_utils.py @@ -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. diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 68f5bed31f..8307bb7cf8 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -23,7 +23,7 @@ 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 init( 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 init( 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 init( 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 init( 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 init( 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 init( 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 init( 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 init( 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 init( 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 init( 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]") diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 469ecbfbd7..4558b922ae 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -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: `-` (e.g., `003-user-auth` or `20260319-143022-user-auth`) - Set `SPECIFY_FEATURE_DIRECTORY` to `specs/` + - 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` diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 460db4897e..30bcb015d1 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -63,7 +63,7 @@ def test_integration_copilot_creates_files(self, tmp_path): 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 @@ def fail_select(*_args, **_kwargs): 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 @@ def test_integration_copilot_auto_promotes(self, tmp_path): 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 @@ def fail_install(self, path, version): "copilot", "--script", "sh", - "--no-git", "--preset", "lean", ], @@ -192,7 +191,7 @@ def test_integration_claude_here_preserves_preexisting_commands(self, tmp_path): 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 @@ def test_init_here_force_overwrites_shared_infra(self, tmp_path): "init", "--here", "--force", "--integration", "copilot", "--script", "sh", - "--no-git", ], catch_exceptions=False) finally: os.chdir(old_cwd) @@ -663,7 +661,6 @@ def test_init_here_without_force_preserves_shared_infra(self, tmp_path): "init", "--here", "--integration", "copilot", "--script", "sh", - "--no-git", ], input="y\n", catch_exceptions=False) finally: os.chdir(old_cwd) @@ -692,7 +689,7 @@ def test_force_merges_into_existing_dir(self, tmp_path): 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 @@ def test_without_force_errors_on_existing_dir(self, tmp_path): 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,54 +742,16 @@ def test_git_extension_auto_installed(self, tmp_path): 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 - 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() - - # 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.""" - from typer.testing import CliRunner - from specify_cli import app - - project = tmp_path / "no-git" - 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) - - assert result.exit_code == 0, f"init failed: {result.output}" - - # Git extension should NOT be installed + # Git extension directory should NOT be present after init ext_dir = project / ".specify" / "extensions" / "git" - assert not ext_dir.exists(), "git extension should not be installed with --no-git" + assert not ext_dir.exists(), "git extension should not be auto-installed" - def test_no_git_emits_deprecation_warning(self, tmp_path): - """Using --no-git emits a visible deprecation warning.""" + 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-warn" + project = tmp_path / "no-git-rejected" project.mkdir() old_cwd = os.getcwd() try: @@ -801,51 +760,19 @@ def test_no_git_emits_deprecation_warning(self, tmp_path): 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 + 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() - 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.""" + 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 / "git-cmds" + project = tmp_path / "git-cmds-absent" project.mkdir() old_cwd = os.getcwd() try: @@ -860,11 +787,11 @@ def test_git_extension_commands_registered(self, tmp_path): 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 @@ def test_full_init_claude_resolves_page_templates(self, tmp_path): "init", str(project), "--integration", "claude", "--script", "sh", - "--no-git", "--ignore-agent-tools", ], catch_exceptions=False) finally: @@ -1014,7 +940,6 @@ def test_full_init_copilot_resolves_page_templates(self, tmp_path): "init", str(project), "--integration", "copilot", "--script", "sh", - "--no-git", "--ignore-agent-tools", ], catch_exceptions=False) finally: @@ -1046,7 +971,6 @@ def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path): "--integration", "copilot", "--integration-options", "--skills", "--script", "sh", - "--no-git", "--ignore-agent-tools", ], catch_exceptions=False) finally: diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py index fd7b82e120..b64a609e15 100644 --- a/tests/integrations/test_integration_agy.py +++ b/tests/integrations/test_integration_agy.py @@ -39,7 +39,7 @@ def test_integration_agy_creates_skills(self, tmp_path): 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 @@ def test_agy_setup_warning(self, tmp_path): # 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 diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index 5d1222c965..19b52167a9 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -192,7 +192,7 @@ def test_integration_flag_auto_promotes(self, tmp_path): 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 @@ def test_integration_flag_creates_files(self, tmp_path): 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 @@ def test_init_options_includes_context_file(self, tmp_path): 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 @@ def test_complete_file_inventory_sh(self, tmp_path): 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 @@ def test_complete_file_inventory_ps(self, tmp_path): 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" diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index ca30831985..8a3f9d0f34 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -325,7 +325,7 @@ def test_integration_flag_auto_promotes(self, tmp_path): 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 @@ def test_integration_flag_creates_files(self, tmp_path): 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 @@ def test_init_options_includes_context_file(self, tmp_path): 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 @@ def test_complete_file_inventory_sh(self, tmp_path): 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 @@ def test_complete_file_inventory_ps(self, tmp_path): 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, ( diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index eac51bd206..37f6966e35 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -409,7 +409,6 @@ def test_integration_flag_auto_promotes(self, tmp_path): self.KEY, "--script", "sh", - "--no-git", "--ignore-agent-tools", ], catch_exceptions=False, @@ -440,7 +439,6 @@ def test_integration_flag_creates_files(self, tmp_path): self.KEY, "--script", "sh", - "--no-git", "--ignore-agent-tools", ], catch_exceptions=False, @@ -469,7 +467,7 @@ def test_init_options_includes_context_file(self, tmp_path): 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 @@ def test_complete_file_inventory_sh(self, tmp_path): self.KEY, "--script", "sh", - "--no-git", "--ignore-agent-tools", ], catch_exceptions=False, @@ -589,7 +586,7 @@ def test_complete_file_inventory_sh(self, tmp_path): 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 @@ def test_complete_file_inventory_ps(self, tmp_path): self.KEY, "--script", "ps", - "--no-git", "--ignore-agent-tools", ], catch_exceptions=False, @@ -625,7 +621,7 @@ def test_complete_file_inventory_ps(self, tmp_path): 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, ( diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index a1a02b7811..7814844c51 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -288,7 +288,6 @@ def test_integration_flag_auto_promotes(self, tmp_path): self.KEY, "--script", "sh", - "--no-git", "--ignore-agent-tools", ], catch_exceptions=False, @@ -319,7 +318,6 @@ def test_integration_flag_creates_files(self, tmp_path): self.KEY, "--script", "sh", - "--no-git", "--ignore-agent-tools", ], catch_exceptions=False, @@ -348,7 +346,7 @@ def test_init_options_includes_context_file(self, tmp_path): 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 @@ def test_complete_file_inventory_sh(self, tmp_path): self.KEY, "--script", "sh", - "--no-git", "--ignore-agent-tools", ], catch_exceptions=False, @@ -468,7 +465,7 @@ def test_complete_file_inventory_sh(self, tmp_path): 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 @@ def test_complete_file_inventory_ps(self, tmp_path): self.KEY, "--script", "ps", - "--no-git", "--ignore-agent-tools", ], catch_exceptions=False, @@ -504,7 +500,7 @@ def test_complete_file_inventory_ps(self, tmp_path): 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, ( diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py index 2e285e17e1..fae9e32d23 100644 --- a/tests/integrations/test_integration_catalog.py +++ b/tests/integrations/test_integration_catalog.py @@ -458,7 +458,6 @@ def _init_project(self, tmp_path): "init", "--here", "--integration", "copilot", "--script", "sh", - "--no-git", "--ignore-agent-tools", ], catch_exceptions=False) finally: @@ -556,7 +555,6 @@ def _init_project(self, tmp_path, integration="copilot"): "init", "--here", "--integration", integration, "--script", "sh", - "--no-git", "--ignore-agent-tools", ], catch_exceptions=False) finally: diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index e1bda1fb68..0a5a2b18f6 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -137,7 +137,6 @@ def test_integration_flag_creates_skill_files_cli(self, tmp_path): "claude", "--script", "sh", - "--no-git", "--ignore-agent-tools", ], catch_exceptions=False, @@ -175,7 +174,6 @@ def test_integration_flag_creates_skill_files(self, tmp_path): "claude", "--script", "sh", - "--no-git", "--ignore-agent-tools", ], catch_exceptions=False, @@ -208,7 +206,6 @@ def test_interactive_claude_selection_uses_integration_path(self, tmp_path): "--here", "--script", "sh", - "--no-git", "--ignore-agent-tools", ], catch_exceptions=False, @@ -243,7 +240,7 @@ def test_claude_init_remains_usable_when_converter_fails(self, tmp_path): 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 diff --git a/tests/integrations/test_integration_cline.py b/tests/integrations/test_integration_cline.py index 5fd723dfc9..a2f9b632ab 100644 --- a/tests/integrations/test_integration_cline.py +++ b/tests/integrations/test_integration_cline.py @@ -139,7 +139,6 @@ def test_integration_flag_creates_files(self, tmp_path): self.KEY, "--script", "sh", - "--no-git", "--ignore-agent-tools", ], catch_exceptions=False, diff --git a/tests/integrations/test_integration_codex.py b/tests/integrations/test_integration_codex.py index 8ce0d82d64..bb3b477fcc 100644 --- a/tests/integrations/test_integration_codex.py +++ b/tests/integrations/test_integration_codex.py @@ -24,7 +24,7 @@ def test_integration_codex_creates_skills(self, tmp_path): 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() diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index d74fd52a3e..d5b3c1deeb 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -186,12 +186,12 @@ def test_complete_file_inventory_sh(self, tmp_path): 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 @@ def test_complete_file_inventory_ps(self, tmp_path): 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 @@ def test_init_with_integration_options_skills(self, tmp_path): 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 @@ def test_complete_file_inventory_skills_sh(self, tmp_path): 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 @@ def test_init_skills_next_steps_show_skill_syntax(self, tmp_path): result = CliRunner().invoke(app, [ "init", "--here", "--integration", "copilot", "--integration-options", "--skills", - "--script", "sh", "--no-git", ], catch_exceptions=False) finally: os.chdir(old_cwd) diff --git a/tests/integrations/test_integration_cursor_agent.py b/tests/integrations/test_integration_cursor_agent.py index f03238a4ea..8165464655 100644 --- a/tests/integrations/test_integration_cursor_agent.py +++ b/tests/integrations/test_integration_cursor_agent.py @@ -102,7 +102,7 @@ def test_integration_cursor_agent_creates_skills(self, tmp_path): 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() diff --git a/tests/integrations/test_integration_devin.py b/tests/integrations/test_integration_devin.py index 642a55ce62..4acbdac618 100644 --- a/tests/integrations/test_integration_devin.py +++ b/tests/integrations/test_integration_devin.py @@ -68,7 +68,7 @@ def test_integration_devin_creates_skills(self, tmp_path): 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}" diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index c0ef38c2e9..b7c64cdf67 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -251,7 +251,6 @@ def test_cli_generic_without_commands_dir_fails(self, tmp_path): 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 @@ def test_init_options_includes_context_file(self, tmp_path): 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 @@ def test_complete_file_inventory_sh(self, tmp_path): 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 @@ def test_complete_file_inventory_ps(self, tmp_path): 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", diff --git a/tests/integrations/test_integration_hermes.py b/tests/integrations/test_integration_hermes.py index d19a2c32ab..89e74c2b38 100644 --- a/tests/integrations/test_integration_hermes.py +++ b/tests/integrations/test_integration_hermes.py @@ -232,7 +232,7 @@ def test_complete_file_inventory_sh(self, tmp_path, monkeypatch): 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 @@ def test_complete_file_inventory_ps(self, tmp_path, monkeypatch): 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 @@ def test_integration_hermes_creates_global_skills(self, tmp_path, monkeypatch): result = runner.invoke(app, [ "init", str(target), "--integration", "hermes", - "--no-git", "--ignore-agent-tools", "--script", "sh", ]) diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py index 6cad3b00b3..112baf0301 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -137,7 +137,7 @@ def test_next_steps_show_skill_invocation(self, tmp_path): 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: diff --git a/tests/integrations/test_integration_kiro_cli.py b/tests/integrations/test_integration_kiro_cli.py index da68e21a2e..c1a029a55f 100644 --- a/tests/integrations/test_integration_kiro_cli.py +++ b/tests/integrations/test_integration_kiro_cli.py @@ -140,7 +140,7 @@ def test_integration_kiro_cli_creates_files(self, tmp_path): 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) diff --git a/tests/integrations/test_integration_rovodev.py b/tests/integrations/test_integration_rovodev.py index 54e0c7a6f1..8e992476fb 100644 --- a/tests/integrations/test_integration_rovodev.py +++ b/tests/integrations/test_integration_rovodev.py @@ -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: diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index fd9eada5cc..c614d691fc 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -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 @@ def test_switch_does_not_register_disabled_extensions(self, tmp_path): 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 @@ def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path): 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 @@ def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path): 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 @@ def test_switch_preserves_user_customized_shared_infra(self, tmp_path): 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() diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index 18803c485f..08bf0ba8fb 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -254,7 +254,6 @@ def test_safe_integrations_have_disjoint_manifests( initial, "--script", "sh", - "--no-git", "--ignore-agent-tools", ], catch_exceptions=False, diff --git a/tests/test_branch_numbering.py b/tests/test_branch_numbering.py index 4d10cd14cf..f2db7cb52f 100644 --- a/tests/test_branch_numbering.py +++ b/tests/test_branch_numbering.py @@ -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): - 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): + 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", "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() diff --git a/tests/test_check_prerequisites_paths_only.py b/tests/test_check_prerequisites_paths_only.py index 2e03028001..0675da3113 100644 --- a/tests/test_check_prerequisites_paths_only.py +++ b/tests/test_check_prerequisites_paths_only.py @@ -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 diff --git a/tests/test_setup_plan_feature_json.py b/tests/test_setup_plan_feature_json.py index 0203b36705..7f44a47923 100644 --- a/tests/test_setup_plan_feature_json.py +++ b/tests/test_setup_plan_feature_json.py @@ -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 diff --git a/tests/test_setup_plan_no_overwrite.py b/tests/test_setup_plan_no_overwrite.py index f29a629294..23c4a32335 100644 --- a/tests/test_setup_plan_no_overwrite.py +++ b/tests/test_setup_plan_no_overwrite.py @@ -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 diff --git a/tests/test_setup_tasks.py b/tests/test_setup_tasks.py index 961124d3a9..233f342664 100644 --- a/tests/test_setup_tasks.py +++ b/tests/test_setup_tasks.py @@ -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 diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 3f6d8bd2a8..826ac9703a 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -279,64 +279,13 @@ def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self): @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 +293,11 @@ def test_rejects_malformed_timestamp_with_prefix(self): @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 +305,29 @@ def test_timestamp_with_single_path_prefix_cross_branch(self, tmp_path: Path): 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 +339,8 @@ def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path): 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 +360,11 @@ def test_env_var(self): @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 +373,65 @@ def test_no_git_timestamp(self, no_git_dir: Path): @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() + 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 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 + 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 +439,22 @@ def test_e2e_sequential(self, git_repo: Path): @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 +463,11 @@ def test_without_flag_still_errors(self, git_repo: Path): 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 +475,20 @@ def test_allow_existing_no_overwrite_spec(self, git_repo: Path): 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 +498,26 @@ def test_allow_existing_with_json(self, git_repo: Path): 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 +592,27 @@ def test_dry_run_sequential_outputs_name(self, git_repo: Path): 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 +677,22 @@ def test_dry_run_then_real_run_match(self, git_repo: Path): 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, - ) - 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, - ) - - # 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, + ["git", "checkout", "-b", "005-git-only"], + cwd=git_repo, + check=True, + capture_output=True, ) subprocess.run( - ["git", "push", "origin", "005-remote-only"], - cwd=second_clone, check=True, capture_output=True, + ["git", "checkout", "-"], + cwd=git_repo, + 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 +701,7 @@ def test_dry_run_accounts_for_remote_branches(self, git_repo: Path): 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 +727,14 @@ def test_dry_run_json_absent_without_flag(self, git_repo: Path): 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 +745,14 @@ def test_dry_run_with_timestamp(self, git_repo: Path): 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 +812,27 @@ def test_ps_dry_run_outputs_name(self, ps_git_repo: Path): 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.""" @@ -1230,9 +1060,8 @@ def test_env_var_takes_priority_over_feature_json(self, git_repo: Path): 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 +1071,8 @@ def test_fallback_to_branch_lookup(self, git_repo: Path): 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): diff --git a/tests/test_utils_assets_imports.py b/tests/test_utils_assets_imports.py index df79e3e018..8a41fa5e97 100644 --- a/tests/test_utils_assets_imports.py +++ b/tests/test_utils_assets_imports.py @@ -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 @@ 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()