From 1e794d87e736103bedc737ea6a6090674e3f1af3 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:20:39 -0500 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20Git=20extension=20stage=202=20?= =?UTF-8?q?=E2=80=94=20GIT=5FBRANCH=5FNAME=20override,=20--force=20for=20e?= =?UTF-8?q?xisting=20dirs,=20auto-install=20tests=20(#1940)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GIT_BRANCH_NAME env var override to create-new-feature.sh/.ps1 for exact branch naming (bypasses all prefix/suffix generation) - Fix --force flag for 'specify init ' into existing directories - Add TestGitExtensionAutoInstall tests (auto-install, --no-git skip, commands registered) - Add TestFeatureDirectoryResolution tests (env var, feature.json, priority, branch fallback) - Document GIT_BRANCH_NAME in speckit.git.feature.md and specify.md --- .../git/commands/speckit.git.feature.md | 25 ++-- .../git/scripts/bash/create-new-feature.sh | 110 +++++++-------- .../scripts/powershell/create-new-feature.ps1 | 88 ++++++------ scripts/bash/common.sh | 23 +++- scripts/powershell/common.ps1 | 19 ++- src/specify_cli/__init__.py | 130 ++++-------------- templates/commands/specify.md | 74 ++++++---- tests/extensions/git/test_git_extension.py | 22 +-- tests/integrations/test_cli.py | 93 +++++++++++++ tests/test_timestamp_branches.py | 105 ++++++++++++++ 10 files changed, 421 insertions(+), 268 deletions(-) diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md index 13a7d0784d..1a9c5e35da 100644 --- a/extensions/git/commands/speckit.git.feature.md +++ b/extensions/git/commands/speckit.git.feature.md @@ -4,7 +4,7 @@ description: "Create a feature branch with sequential or timestamp numbering" # Create Feature Branch -Create a new feature branch for the given specification. +Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow. ## User Input @@ -14,10 +14,17 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Environment Variable Override + +If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set: +- The script uses the exact value as the branch name, bypassing all prefix/suffix generation +- `--short-name`, `--number`, and `--timestamp` flags are ignored +- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name + ## Prerequisites - Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` -- If Git is not available, warn the user and skip branch creation (spec directory will still be created) +- If Git is not available, warn the user and skip branch creation ## Branch Numbering Mode @@ -45,22 +52,16 @@ Run the appropriate script based on your platform: - Do NOT pass `--number` — the script determines the correct next number automatically - Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably - You must only ever run this script once per feature -- The JSON output will contain BRANCH_NAME and SPEC_FILE paths - -If the extension scripts are not found at the `.specify/extensions/git/` path, fall back to: -- **Bash**: `scripts/bash/create-new-feature.sh` -- **PowerShell**: `scripts/powershell/create-new-feature.ps1` +- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM` ## Graceful Degradation If Git is not installed or the current directory is not a Git repository: -- The script will still create the spec directory under `specs/` -- A warning will be printed: `[specify] Warning: Git repository not detected; skipped branch creation` -- The workflow continues normally without branch creation +- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation` +- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them ## Output The script outputs JSON with: -- `BRANCH_NAME`: The created branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`) -- `SPEC_FILE`: Path to the created spec file +- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`) - `FEATURE_NUM`: The numeric or timestamp prefix used diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh index dfae29df73..0e72ce759f 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -71,10 +71,14 @@ while [ $i -le $# ]; do echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" echo " --help, -h Show this help message" echo "" + echo "Environment variables:" + echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" + echo "" echo "Examples:" echo " $0 'Add user authentication system' --short-name 'user-auth'" echo " $0 'Implement OAuth2 integration for API' --number 5" echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" + echo " GIT_BRANCH_NAME=my-branch $0 'feature description'" exit 0 ;; *) @@ -258,9 +262,6 @@ fi cd "$REPO_ROOT" SPECS_DIR="$REPO_ROOT/specs" -if [ "$DRY_RUN" != true ]; then - mkdir -p "$SPECS_DIR" -fi # Function to generate branch name with stop word filtering generate_branch_name() { @@ -301,40 +302,53 @@ generate_branch_name() { fi } -# Generate branch name -if [ -n "$SHORT_NAME" ]; then - BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") +# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) +if [ -n "${GIT_BRANCH_NAME:-}" ]; then + BRANCH_NAME="$GIT_BRANCH_NAME" + # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix + if echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then + FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+') + elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}') + else + FEATURE_NUM="$BRANCH_NAME" + fi else - BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") -fi + # Generate branch name + if [ -n "$SHORT_NAME" ]; then + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") + else + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") + fi -# Warn if --number and --timestamp are both specified -if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then - >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" - BRANCH_NUMBER="" -fi + # Warn if --number and --timestamp are both specified + if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" + BRANCH_NUMBER="" + fi -# Determine branch prefix -if [ "$USE_TIMESTAMP" = true ]; then - FEATURE_NUM=$(date +%Y%m%d-%H%M%S) - BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" -else - if [ -z "$BRANCH_NUMBER" ]; then - if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then - BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) - elif [ "$DRY_RUN" = true ]; then - HIGHEST=$(get_highest_from_specs "$SPECS_DIR") - BRANCH_NUMBER=$((HIGHEST + 1)) - elif [ "$HAS_GIT" = true ]; then - BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") - else - HIGHEST=$(get_highest_from_specs "$SPECS_DIR") - BRANCH_NUMBER=$((HIGHEST + 1)) + # Determine branch prefix + if [ "$USE_TIMESTAMP" = true ]; then + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + else + if [ -z "$BRANCH_NUMBER" ]; then + if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) + elif [ "$DRY_RUN" = true ]; then + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + elif [ "$HAS_GIT" = true ]; then + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") + else + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + fi fi - fi - FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") - BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + fi fi # GitHub enforces a 244-byte limit on branch names @@ -354,9 +368,6 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" fi -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="" @@ -391,22 +402,6 @@ if [ "$DRY_RUN" != true ]; then >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" fi - mkdir -p "$FEATURE_DIR" - - if [ ! -f "$SPEC_FILE" ]; then - if type resolve_template >/dev/null 2>&1; then - TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true - else - TEMPLATE="" - fi - if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then - cp "$TEMPLATE" "$SPEC_FILE" - else - echo "Warning: Spec template not found; created empty spec file" >&2 - touch "$SPEC_FILE" - fi - fi - printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 fi @@ -415,35 +410,30 @@ if $JSON_MODE; then if [ "$DRY_RUN" = true ]; then jq -cn \ --arg branch_name "$BRANCH_NAME" \ - --arg spec_file "$SPEC_FILE" \ --arg feature_num "$FEATURE_NUM" \ - '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}' + '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}' else jq -cn \ --arg branch_name "$BRANCH_NAME" \ - --arg spec_file "$SPEC_FILE" \ --arg feature_num "$FEATURE_NUM" \ - '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' + '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}' fi else if type json_escape >/dev/null 2>&1; then _je_branch=$(json_escape "$BRANCH_NAME") - _je_spec=$(json_escape "$SPEC_FILE") _je_num=$(json_escape "$FEATURE_NUM") else _je_branch="$BRANCH_NAME" - _je_spec="$SPEC_FILE" _je_num="$FEATURE_NUM" fi if [ "$DRY_RUN" = true ]; then - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_spec" "$_je_num" + printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num" else - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_spec" "$_je_num" + printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num" fi fi else echo "BRANCH_NAME: $BRANCH_NAME" - echo "SPEC_FILE: $SPEC_FILE" echo "FEATURE_NUM: $FEATURE_NUM" if [ "$DRY_RUN" != true ]; then printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 index 75a4e69814..4bd5246259 100644 --- a/extensions/git/scripts/powershell/create-new-feature.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -29,6 +29,10 @@ if ($Help) { 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" + Write-Host "" + Write-Host "Environment variables:" + Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" + Write-Host "" exit 0 } @@ -216,9 +220,6 @@ if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) { Set-Location $repoRoot $specsDir = Join-Path $repoRoot 'specs' -if (-not $DryRun) { - New-Item -ItemType Directory -Path $specsDir -Force | Out-Null -} function Get-BranchName { param([string]$Description) @@ -255,35 +256,48 @@ function Get-BranchName { } } -if ($ShortName) { - $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName +# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) +if ($env:GIT_BRANCH_NAME) { + $branchName = $env:GIT_BRANCH_NAME + # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix + if ($branchName -match '^(\d+)-') { + $featureNum = $matches[1] + } elseif ($branchName -match '^(\d{8}-\d{6})-') { + $featureNum = $matches[1] + } else { + $featureNum = $branchName + } } else { - $branchSuffix = Get-BranchName -Description $featureDesc -} + if ($ShortName) { + $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName + } else { + $branchSuffix = Get-BranchName -Description $featureDesc + } -if ($Timestamp -and $Number -ne 0) { - Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" - $Number = 0 -} + if ($Timestamp -and $Number -ne 0) { + Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" + $Number = 0 + } -if ($Timestamp) { - $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' - $branchName = "$featureNum-$branchSuffix" -} else { - if ($Number -eq 0) { - if ($DryRun -and $hasGit) { - $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch - } elseif ($DryRun) { - $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 - } elseif ($hasGit) { - $Number = Get-NextBranchNumber -SpecsDir $specsDir - } else { - $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + if ($Timestamp) { + $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' + $branchName = "$featureNum-$branchSuffix" + } else { + if ($Number -eq 0) { + if ($DryRun -and $hasGit) { + $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch + } elseif ($DryRun) { + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } elseif ($hasGit) { + $Number = Get-NextBranchNumber -SpecsDir $specsDir + } else { + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } } - } - $featureNum = ('{0:000}' -f $Number) - $branchName = "$featureNum-$branchSuffix" + $featureNum = ('{0:000}' -f $Number) + $branchName = "$featureNum-$branchSuffix" + } } $maxBranchLength = 244 @@ -302,9 +316,6 @@ if ($branchName.Length -gt $maxBranchLength) { Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" } -$featureDir = Join-Path $specsDir $branchName -$specFile = Join-Path $featureDir 'spec.md' - if (-not $DryRun) { if ($hasGit) { $branchCreated = $false @@ -357,28 +368,12 @@ if (-not $DryRun) { } } - New-Item -ItemType Directory -Path $featureDir -Force | Out-Null - - if (-not (Test-Path -PathType Leaf $specFile)) { - if (Get-Command Resolve-Template -ErrorAction SilentlyContinue) { - $template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot - } else { - $template = $null - } - if ($template -and (Test-Path $template)) { - Copy-Item $template $specFile -Force - } else { - New-Item -ItemType File -Path $specFile -Force | Out-Null - } - } - $env:SPECIFY_FEATURE = $branchName } if ($Json) { $obj = [PSCustomObject]@{ BRANCH_NAME = $branchName - SPEC_FILE = $specFile FEATURE_NUM = $featureNum HAS_GIT = $hasGit } @@ -388,7 +383,6 @@ if ($Json) { $obj | ConvertTo-Json -Compress } else { Write-Output "BRANCH_NAME: $branchName" - Write-Output "SPEC_FILE: $specFile" Write-Output "FEATURE_NUM: $featureNum" Write-Output "HAS_GIT: $hasGit" if (-not $DryRun) { diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 5e45e8708c..c38ed5f44e 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -194,9 +194,28 @@ get_feature_paths() { has_git_repo="true" fi - # Use prefix-based lookup to support multiple branches per spec + # Resolve feature directory. Priority: + # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) + # 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify) + # 3. Branch-name-based prefix lookup (legacy fallback) local feature_dir - if ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then + if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then + feature_dir="$SPECIFY_FEATURE_DIRECTORY" + elif [[ -f "$repo_root/.specify/feature.json" ]]; then + local _fd + if command -v jq >/dev/null 2>&1; then + _fd=$(jq -r '.feature_directory // empty' "$repo_root/.specify/feature.json" 2>/dev/null) + else + # Minimal fallback: extract value with grep/sed when jq is unavailable + _fd=$(grep -o '"feature_directory"[[:space:]]*:[[:space:]]*"[^"]*"' "$repo_root/.specify/feature.json" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/') + fi + if [[ -n "$_fd" ]]; then + feature_dir="$_fd" + elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then + echo "ERROR: Failed to resolve 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 return 1 fi diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 8c8c801ee3..7d4cc670a3 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -160,7 +160,24 @@ function Get-FeaturePathsEnv { $repoRoot = Get-RepoRoot $currentBranch = Get-CurrentBranch $hasGit = Test-HasGit - $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch + + # Resolve feature directory. Priority: + # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) + # 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify) + # 3. Branch-name-based prefix lookup (legacy fallback) + $featureJson = Join-Path $repoRoot '.specify/feature.json' + if ($env:SPECIFY_FEATURE_DIRECTORY) { + $featureDir = $env:SPECIFY_FEATURE_DIRECTORY + } elseif (Test-Path $featureJson) { + $featureConfig = Get-Content $featureJson -Raw | ConvertFrom-Json + if ($featureConfig.feature_directory) { + $featureDir = $featureConfig.feature_directory + } else { + $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch + } + } else { + $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch + } [PSCustomObject]@{ REPO_ROOT = $repoRoot diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 95ab2028c1..609daf7315 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -384,61 +384,6 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool: return found -def is_git_repo(path: Path = None) -> bool: - """Check if the specified path is inside a git repository.""" - if path is None: - path = Path.cwd() - - if not path.is_dir(): - return False - - try: - # Use git command to check if inside a work tree - 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, Optional[str]]: - """Initialize a git repository in the specified path. - - Args: - project_path: Path to initialize git repository in - quiet: if True suppress console output (tracker handles status) - - Returns: - Tuple of (success: bool, error_message: Optional[str]) - """ - 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. @@ -1011,16 +956,21 @@ def init( else: project_path = Path(project_name).resolve() if project_path.exists(): - error_panel = Panel( - f"Directory '[cyan]{project_name}[/cyan]' already exists\n" - "Please choose a different project name or remove the existing directory.", - title="[red]Directory Conflict[/red]", - border_style="red", - padding=(1, 2) - ) - console.print() - console.print(error_panel) - raise typer.Exit(1) + existing_items = list(project_path.iterdir()) + if force: + console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]") + else: + error_panel = Panel( + f"Directory '[cyan]{project_name}[/cyan]' already exists\n" + "Please choose a different project name or remove the existing directory.\n" + "Use [bold]--force[/bold] to merge into the existing directory.", + title="[red]Directory Conflict[/red]", + border_style="red", + padding=(1, 2) + ) + console.print() + console.print(error_panel) + raise typer.Exit(1) if ai_assistant: if ai_assistant not in AGENT_CONFIG: @@ -1066,11 +1016,7 @@ 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) @@ -1123,14 +1069,11 @@ def init( for key, label in [ ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), - ("git", "Initialize git repository"), + ("git", "Install git extension"), ("final", "Finalize"), ]: tracker.add(key, label) - # Track git error message outside Live context so it persists - git_error_message = None - with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: @@ -1183,17 +1126,19 @@ def init( if not no_git: tracker.start("git") - if is_git_repo(project_path): - tracker.complete("git", "existing repo detected") - elif should_init_git: - success, error_msg = init_git_repo(project_path, quiet=True) - if success: - tracker.complete("git", "initialized") + try: + from .extensions import ExtensionManager + bundled_path = _locate_bundled_extension("git") + if bundled_path: + manager = ExtensionManager(project_path) + manager.install_from_directory( + bundled_path, get_speckit_version() + ) + tracker.complete("git", "git extension installed") else: - tracker.error("git", "init failed") - git_error_message = error_msg - else: - tracker.skip("git", "git not available") + tracker.skip("git", "bundled git extension not found") + except Exception as ext_err: + tracker.error("git", f"install failed: {ext_err}") else: tracker.skip("git", "--no-git flag") @@ -1271,23 +1216,6 @@ def init( console.print(tracker.render()) console.print("\n[bold green]Project ready.[/bold green]") - # Show git error details if initialization failed - if git_error_message: - console.print() - git_error_panel = Panel( - f"[yellow]Warning:[/yellow] Git repository initialization failed\n\n" - f"{git_error_message}\n\n" - f"[dim]You can initialize git manually later with:[/dim]\n" - f"[cyan]cd {project_path if not here else '.'}[/cyan]\n" - f"[cyan]git init[/cyan]\n" - f"[cyan]git add .[/cyan]\n" - f"[cyan]git commit -m \"Initial commit\"[/cyan]", - title="[red]Git Initialization Failed[/red]", - border_style="red", - padding=(1, 2) - ) - console.print(git_error_panel) - # Agent folder security notice agent_config = AGENT_CONFIG.get(selected_ai) if agent_config: diff --git a/templates/commands/specify.md b/templates/commands/specify.md index a81b8f12f1..bd5ca2a108 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -8,9 +8,6 @@ handoffs: agent: speckit.clarify prompt: Clarify specification requirements send: true -scripts: - sh: scripts/bash/create-new-feature.sh "{ARGS}" - ps: scripts/powershell/create-new-feature.ps1 "{ARGS}" --- ## User Input @@ -61,7 +58,7 @@ The text the user typed after `/speckit.specify` in the triggering message **is* Given that feature description, do this: -1. **Generate a concise short name** (2-4 words) for the branch: +1. **Generate a concise short name** (2-4 words) for the feature: - Analyze the feature description and extract the most meaningful keywords - Create a 2-4 word short name that captures the essence of the feature - Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug") @@ -73,30 +70,47 @@ Given that feature description, do this: - "Create a dashboard for analytics" → "analytics-dashboard" - "Fix payment processing timeout bug" → "fix-payment-timeout" -2. **Create the feature branch** by running the script with `--short-name` (and `--json`). In sequential mode, do NOT pass `--number` — the script auto-detects the next available number. In timestamp mode, the script generates a `YYYYMMDD-HHMMSS` prefix automatically: +2. **Branch creation** (optional, via hook): - **Branch numbering mode**: Before running the script, check if `.specify/init-options.json` exists and read the `branch_numbering` value. - - If `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation - - If `"sequential"` or absent, do not add any extra flag (default behavior) + If a `before_specify` hook ran successfully in the Pre-Execution Checks above, it will have created/switched to a git branch and output JSON containing `BRANCH_NAME` and `FEATURE_NUM`. Note these values for reference, but the branch name does **not** dictate the spec directory name. - - Bash example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"` - - Bash (timestamp): `{SCRIPT} --json --timestamp --short-name "user-auth" "Add user authentication"` - - PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"` - - PowerShell (timestamp): `{SCRIPT} -Json -Timestamp -ShortName "user-auth" "Add user authentication"` + If the user explicitly provided `GIT_BRANCH_NAME`, pass it through to the hook so the branch script uses the exact value as the branch name (bypassing all prefix/suffix generation). - **IMPORTANT**: - - Do NOT pass `--number` — the script determines the correct next number automatically - - Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably - - You must only ever run this script once per feature - - The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for - - The JSON output will contain BRANCH_NAME and SPEC_FILE paths - - For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot") +3. **Create the spec feature directory**: + + Two key variables control where specs live: + - `SPECIFY_SPEC_DIRECTORY`: the top-level directory for all specs (default: `specs/`) + - `SPECIFY_FEATURE_DIRECTORY`: the directory for this specific feature, always a subdirectory of `SPECIFY_SPEC_DIRECTORY` + + **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: + - Check `.specify/init-options.json` for `branch_numbering` + - 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 `SPECIFY_SPEC_DIRECTORY`) + - Construct the directory name: `-` (e.g., `003-user-auth` or `20260319-143022-user-auth`) + - Set `SPECIFY_FEATURE_DIRECTORY` to `SPECIFY_SPEC_DIRECTORY/` -3. Load `templates/spec-template.md` to understand required sections. + **Create the directory and spec file**: + - `mkdir -p SPECIFY_FEATURE_DIRECTORY` + - Copy `templates/spec-template.md` to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point + - Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md` + - Persist the resolved path to `.specify/feature.json`: + ```json + { + "feature_directory": "SPECIFY_FEATURE_DIRECTORY" + } + ``` + This allows downstream commands (`/speckit.plan`, `/speckit.tasks`, etc.) to locate the feature directory without relying on git branch name conventions. + + **IMPORTANT**: + - You must only create one feature per `/speckit.specify` invocation + - The spec directory name and the git branch name are independent — they may be the same but that is the user's choice + - The spec directory and file are always created by this command, never by the hook -4. Follow this execution flow: +4. Load `templates/spec-template.md` to understand required sections. - 1. Parse user description from Input +5. Follow this execution flow: If empty: ERROR "No feature description provided" 2. Extract key concepts from description Identify: actors, actions, data, constraints @@ -120,11 +134,11 @@ Given that feature description, do this: 7. Identify Key Entities (if data involved) 8. Return: SUCCESS (spec ready for planning) -5. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings. +6. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings. -6. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria: +7. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria: - a. **Create Spec Quality Checklist**: Generate a checklist file at `FEATURE_DIR/checklists/requirements.md` using the checklist template structure with these validation items: + a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items: ```markdown # Specification Quality Checklist: [FEATURE NAME] @@ -214,9 +228,13 @@ Given that feature description, do this: d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status -7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`). +8. **Report completion** to the user with: + - `SPECIFY_FEATURE_DIRECTORY` — the feature directory path + - `SPEC_FILE` — the spec file path + - Checklist results summary + - Readiness for the next phase (`/speckit.clarify` or `/speckit.plan`) -8. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root. +9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.after_specify` key - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. @@ -245,7 +263,7 @@ Given that feature description, do this: ``` - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently -**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing. +**NOTE:** Branch creation is handled by the `before_specify` hook (git extension). Spec directory and file creation are always handled by this core command. ## Quick Guidelines diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 721bd999f2..098caf53b7 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -280,7 +280,6 @@ def test_creates_branch_sequential(self, tmp_path: Path): assert result.returncode == 0, result.stderr data = json.loads(result.stdout) assert data["BRANCH_NAME"] == "001-user-auth" - assert "SPEC_FILE" in data assert data["FEATURE_NUM"] == "001" def test_creates_branch_timestamp(self, tmp_path: Path): @@ -294,18 +293,6 @@ def test_creates_branch_timestamp(self, tmp_path: Path): data = json.loads(result.stdout) assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"]) - def test_creates_spec_dir(self, tmp_path: Path): - """create-new-feature.sh creates specs directory and spec.md.""" - project = _setup_project(tmp_path) - result = _run_bash( - "create-new-feature.sh", project, - "--json", "--short-name", "test-feat", "Test feature", - ) - assert result.returncode == 0, result.stderr - data = json.loads(result.stdout) - spec_file = Path(data["SPEC_FILE"]) - assert spec_file.exists(), f"spec.md not created at {spec_file}" - def test_increments_from_existing_specs(self, tmp_path: Path): """Sequential numbering increments past existing spec directories.""" project = _setup_project(tmp_path) @@ -321,7 +308,7 @@ def test_increments_from_existing_specs(self, tmp_path: Path): assert data["FEATURE_NUM"] == "003" def test_no_git_graceful_degradation(self, tmp_path: Path): - """create-new-feature.sh works without git (creates spec dir only).""" + """create-new-feature.sh works without git (outputs branch name, skips branch creation).""" project = _setup_project(tmp_path, git=False) result = _run_bash( "create-new-feature.sh", project, @@ -330,8 +317,8 @@ def test_no_git_graceful_degradation(self, tmp_path: Path): assert result.returncode == 0, result.stderr assert "Warning" in result.stderr data = json.loads(result.stdout) - spec_file = Path(data["SPEC_FILE"]) - assert spec_file.exists() + assert "BRANCH_NAME" in data + assert "FEATURE_NUM" in data def test_dry_run(self, tmp_path: Path): """--dry-run computes branch name without creating anything.""" @@ -382,7 +369,8 @@ def test_no_git_graceful_degradation(self, tmp_path: Path): json_line = [l for l in result.stdout.splitlines() if l.strip().startswith("{")] assert json_line, f"No JSON in output: {result.stdout}" data = json.loads(json_line[-1]) - assert Path(data["SPEC_FILE"]).exists() + assert "BRANCH_NAME" in data + assert "FEATURE_NUM" in data # ── auto-commit.sh Tests ───────────────────────────────────────────────────── diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 945ce6ac62..e82d6f4085 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -3,6 +3,8 @@ import json import os +import yaml + class TestInitIntegrationFlag: def test_integration_and_ai_mutually_exclusive(self, tmp_path): @@ -147,3 +149,94 @@ def test_shared_infra_skips_existing_files(self, tmp_path): # Other shared files should still be installed assert (scripts_dir / "setup-plan.sh").exists() assert (templates_dir / "plan-template.md").exists() + + +class TestGitExtensionAutoInstall: + """Tests for auto-installation of the git extension during specify init.""" + + def test_git_extension_auto_installed(self, tmp_path): + """Without --no-git, the git extension is installed during init.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "git-auto" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "claude", "--script", "sh", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + 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", "--ai", "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 + ext_dir = project / ".specify" / "extensions" / "git" + assert not ext_dir.exists(), "git extension should not be installed with --no-git" + + def test_git_extension_commands_registered(self, tmp_path): + """Git extension commands are registered with the agent during init.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "git-cmds" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "claude", "--script", "sh", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, f"init failed: {result.output}" + + # Git extension commands should be registered with the agent + claude_skills = project / ".claude" / "skills" + 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" diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 2c13853119..e692b30ada 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -774,3 +774,108 @@ def test_ps_dry_run_json_absent_without_flag(self, ps_git_repo: Path): assert result.returncode == 0, result.stderr data = json.loads(result.stdout) assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}" + + +# ── Feature Directory Resolution Tests ─────────────────────────────────────── + + +class TestFeatureDirectoryResolution: + """Tests for SPECIFY_FEATURE_DIRECTORY and .specify/feature.json resolution.""" + + def test_env_var_overrides_branch_lookup(self, git_repo: Path): + """SPECIFY_FEATURE_DIRECTORY env var takes priority over branch-based lookup.""" + custom_dir = git_repo / "my-custom-specs" / "my-feature" + custom_dir.mkdir(parents=True) + + result = subprocess.run( + ["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'], + cwd=git_repo, + capture_output=True, + text=True, + env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(custom_dir)}, + ) + assert result.returncode == 0, result.stderr + assert str(custom_dir) in result.stdout + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip("'\"") + assert val == str(custom_dir) + break + else: + pytest.fail("FEATURE_DIR not found in output") + + def test_feature_json_overrides_branch_lookup(self, git_repo: Path): + """feature.json feature_directory takes priority over branch-based lookup.""" + custom_dir = git_repo / "specs" / "custom-feature" + custom_dir.mkdir(parents=True) + + feature_json = git_repo / ".specify" / "feature.json" + feature_json.write_text( + f'{{"feature_directory": "{custom_dir}"}}\n', + encoding="utf-8", + ) + + result = subprocess.run( + ["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'], + cwd=git_repo, + 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(custom_dir) + break + else: + pytest.fail("FEATURE_DIR not found in output") + + def test_env_var_takes_priority_over_feature_json(self, git_repo: Path): + """Env var wins over feature.json.""" + env_dir = git_repo / "specs" / "env-feature" + env_dir.mkdir(parents=True) + json_dir = git_repo / "specs" / "json-feature" + json_dir.mkdir(parents=True) + + feature_json = git_repo / ".specify" / "feature.json" + feature_json.write_text( + f'{{"feature_directory": "{json_dir}"}}\n', + encoding="utf-8", + ) + + result = subprocess.run( + ["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'], + cwd=git_repo, + capture_output=True, + text=True, + env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(env_dir)}, + ) + 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(env_dir) + break + else: + pytest.fail("FEATURE_DIR not found in output") + + 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) + spec_dir = git_repo / "specs" / "001-test-feat" + spec_dir.mkdir(parents=True) + + result = subprocess.run( + ["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'], + cwd=git_repo, + 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") From bf7bdbe349b7208d85e946f05e7e43730e98f28d Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:35:32 -0500 Subject: [PATCH 02/13] fix: remove unused Tuple import (ruff F401) --- src/specify_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 609daf7315..dc5bf97f59 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -35,7 +35,7 @@ import stat import yaml from pathlib import Path -from typing import Any, Optional, Tuple +from typing import Any, Optional import typer from rich.console import Console From 355b884b3423dddd06f7c7187b00e5297cf8a0b7 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:39:57 -0500 Subject: [PATCH 03/13] fix: address Copilot review feedback (#2117) - Fix timestamp regex ordering: check YYYYMMDD-HHMMSS before generic numeric prefix in both bash and PowerShell - Set BRANCH_SUFFIX in GIT_BRANCH_NAME override path so 244-byte truncation logic works correctly - Add 244-byte length check for GIT_BRANCH_NAME in PowerShell - Use existing_items for non-empty dir warning with --force - Skip git extension install if already installed (idempotent --force) - Wrap PowerShell feature.json parsing in try/catch for malformed JSON - Fix PS comment: 'prefix lookup' -> 'exact mapping via Get-FeatureDir' - Remove non-functional SPECIFY_SPEC_DIRECTORY from specify.md template --- extensions/git/scripts/bash/create-new-feature.sh | 10 +++++++--- .../git/scripts/powershell/create-new-feature.ps1 | 9 +++++++-- scripts/powershell/common.ps1 | 14 +++++++++----- src/specify_cli/__init__.py | 14 ++++++++++---- templates/commands/specify.md | 10 ++++------ 5 files changed, 37 insertions(+), 20 deletions(-) diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh index 0e72ce759f..7015b862a1 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -306,12 +306,16 @@ generate_branch_name() { if [ -n "${GIT_BRANCH_NAME:-}" ]; then BRANCH_NAME="$GIT_BRANCH_NAME" # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix - if echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then - FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+') - elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern + if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}') + BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" + elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then + FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+') + BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" else FEATURE_NUM="$BRANCH_NAME" + BRANCH_SUFFIX="$BRANCH_NAME" fi else # Generate branch name diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 index 4bd5246259..47f3f494dc 100644 --- a/extensions/git/scripts/powershell/create-new-feature.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -259,10 +259,15 @@ function Get-BranchName { # Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) if ($env:GIT_BRANCH_NAME) { $branchName = $env:GIT_BRANCH_NAME + # Check 244-byte limit for override names + if ($branchName.Length -gt 244) { + throw "GIT_BRANCH_NAME must be 244 bytes or fewer. Provided value is $($branchName.Length) characters; please supply a shorter override branch name." + } # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix - if ($branchName -match '^(\d+)-') { + # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern + if ($branchName -match '^(\d{8}-\d{6})-') { $featureNum = $matches[1] - } elseif ($branchName -match '^(\d{8}-\d{6})-') { + } elseif ($branchName -match '^(\d+)-') { $featureNum = $matches[1] } else { $featureNum = $branchName diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 7d4cc670a3..3d94f6ef87 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -164,15 +164,19 @@ function Get-FeaturePathsEnv { # Resolve feature directory. Priority: # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) # 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify) - # 3. Branch-name-based prefix lookup (legacy fallback) + # 3. Exact branch-to-directory mapping via Get-FeatureDir (legacy fallback) $featureJson = Join-Path $repoRoot '.specify/feature.json' if ($env:SPECIFY_FEATURE_DIRECTORY) { $featureDir = $env:SPECIFY_FEATURE_DIRECTORY } elseif (Test-Path $featureJson) { - $featureConfig = Get-Content $featureJson -Raw | ConvertFrom-Json - if ($featureConfig.feature_directory) { - $featureDir = $featureConfig.feature_directory - } else { + try { + $featureConfig = Get-Content $featureJson -Raw | ConvertFrom-Json + if ($featureConfig.feature_directory) { + $featureDir = $featureConfig.feature_directory + } else { + $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch + } + } catch { $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch } } else { diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index dc5bf97f59..882d02af2e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -958,6 +958,9 @@ def init( if project_path.exists(): existing_items = list(project_path.iterdir()) if force: + if existing_items: + console.print(f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)") + console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]") console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]") else: error_panel = Panel( @@ -1131,10 +1134,13 @@ def init( bundled_path = _locate_bundled_extension("git") if bundled_path: manager = ExtensionManager(project_path) - manager.install_from_directory( - bundled_path, get_speckit_version() - ) - tracker.complete("git", "git extension installed") + if manager.registry.is_installed("git"): + tracker.skip("git", "git extension already installed") + else: + manager.install_from_directory( + bundled_path, get_speckit_version() + ) + tracker.complete("git", "git extension installed") else: tracker.skip("git", "bundled git extension not found") except Exception as ext_err: diff --git a/templates/commands/specify.md b/templates/commands/specify.md index bd5ca2a108..900343df31 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -78,18 +78,16 @@ Given that feature description, do this: 3. **Create the spec feature directory**: - Two key variables control where specs live: - - `SPECIFY_SPEC_DIRECTORY`: the top-level directory for all specs (default: `specs/`) - - `SPECIFY_FEATURE_DIRECTORY`: the directory for this specific feature, always a subdirectory of `SPECIFY_SPEC_DIRECTORY` + Specs live under the default `specs/` directory unless the user explicitly provides `SPECIFY_FEATURE_DIRECTORY`. **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: + 2. Otherwise, auto-generate it under `specs/`: - Check `.specify/init-options.json` for `branch_numbering` - 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 `SPECIFY_SPEC_DIRECTORY`) + - 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 `SPECIFY_SPEC_DIRECTORY/` + - Set `SPECIFY_FEATURE_DIRECTORY` to `specs/` **Create the directory and spec file**: - `mkdir -p SPECIFY_FEATURE_DIRECTORY` From 670f64219508a0bc4926ac830bfe8ed8de3c47b2 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:49:13 -0500 Subject: [PATCH 04/13] fix: address second round of Copilot review feedback (#2117) - Guard shutil.rmtree on init failure: skip cleanup when --force merged into a pre-existing directory (prevents data loss) - Bash: error on GIT_BRANCH_NAME >244 bytes instead of broken truncation - Fix malformed numbered list in specify.md (restore missing step 1) - Add claude_skills.exists() assert before iterdir() in test --- extensions/git/scripts/bash/create-new-feature.sh | 5 ++++- src/specify_cli/__init__.py | 5 ++++- templates/commands/specify.md | 1 + tests/integrations/test_cli.py | 1 + 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh index 7015b862a1..5c8168a25b 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -357,7 +357,10 @@ fi # GitHub enforces a 244-byte limit on branch names MAX_BRANCH_LENGTH=244 -if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then +if [ -n "${GIT_BRANCH_NAME:-}" ] && [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then + >&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer. Provided value is ${#BRANCH_NAME} bytes." + exit 1 +elif [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 882d02af2e..106e0b9d5b 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -938,9 +938,11 @@ def init( 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: project_name = Path.cwd().name project_path = Path.cwd() + dir_existed_before = True existing_items = list(project_path.iterdir()) if existing_items: @@ -955,6 +957,7 @@ def init( raise typer.Exit(0) else: project_path = Path(project_name).resolve() + dir_existed_before = project_path.exists() if project_path.exists(): existing_items = list(project_path.iterdir()) if force: @@ -1213,7 +1216,7 @@ def init( _label_width = max(len(k) for k, _ in _env_pairs) env_lines = [f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" for k, v in _env_pairs] console.print(Panel("\n".join(env_lines), title="Debug Environment", border_style="magenta")) - if not here and project_path.exists(): + if not here and project_path.exists() and not dir_existed_before: shutil.rmtree(project_path) raise typer.Exit(1) finally: diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 900343df31..c1be1d0b5b 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -109,6 +109,7 @@ Given that feature description, do this: 4. Load `templates/spec-template.md` to understand required sections. 5. Follow this execution flow: + 1. Parse user description from arguments If empty: ERROR "No feature description provided" 2. Extract key concepts from description Identify: actors, actions, data, constraints diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index e82d6f4085..e835507d5e 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -238,5 +238,6 @@ def test_git_extension_commands_registered(self, tmp_path): # Git extension commands should be registered with the agent 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" From ee37b5bae45abb734e23e0294ca97905f1f96c6c Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:23:27 -0500 Subject: [PATCH 05/13] fix: use UTF-8 byte count for 244-byte branch name limit (#2117) - Bash: use LC_ALL=C wc -c for byte length instead of ${#VAR} - PowerShell: use [System.Text.Encoding]::UTF8.GetByteCount() instead of .Length (UTF-16 code units) --- extensions/git/scripts/bash/create-new-feature.sh | 8 +++++--- extensions/git/scripts/powershell/create-new-feature.ps1 | 7 ++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh index 5c8168a25b..7d7a886b92 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -357,10 +357,12 @@ fi # GitHub enforces a 244-byte limit on branch names MAX_BRANCH_LENGTH=244 -if [ -n "${GIT_BRANCH_NAME:-}" ] && [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then - >&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer. Provided value is ${#BRANCH_NAME} bytes." +_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; } +BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME") +if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then + >&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes." exit 1 -elif [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then +elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 index 47f3f494dc..843cfc59c0 100644 --- a/extensions/git/scripts/powershell/create-new-feature.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -259,9 +259,10 @@ function Get-BranchName { # Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) if ($env:GIT_BRANCH_NAME) { $branchName = $env:GIT_BRANCH_NAME - # Check 244-byte limit for override names - if ($branchName.Length -gt 244) { - throw "GIT_BRANCH_NAME must be 244 bytes or fewer. Provided value is $($branchName.Length) characters; please supply a shorter override branch name." + # Check 244-byte limit (UTF-8) for override names + $branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName) + if ($branchNameUtf8ByteCount -gt 244) { + throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name." } # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern From 49be364afae62cc43c6fd4b02e66faf3a5543ac1 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:38:29 -0500 Subject: [PATCH 06/13] fix: address third round of review feedback (#2117) - Update --dry-run help text in bash and PowerShell (branch name only) - Fix specify.md JSON example: use concrete path, not literal variable - Add TestForceExistingDirectory tests (merge + error without --force) - Add PowerShell Get-FeaturePathsEnv tests (env var + feature.json) --- .../git/scripts/bash/create-new-feature.sh | 2 +- .../scripts/powershell/create-new-feature.ps1 | 2 +- templates/commands/specify.md | 3 +- tests/integrations/test_cli.py | 47 ++++++++++++++++ tests/test_timestamp_branches.py | 53 +++++++++++++++++++ 5 files changed, 104 insertions(+), 3 deletions(-) diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh index 7d7a886b92..02cd2ec1b6 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -64,7 +64,7 @@ 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 " --dry-run Compute branch name without creating the branch" 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 " --number N Specify branch number manually (overrides auto-detection)" diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 index 843cfc59c0..291607d1bd 100644 --- a/extensions/git/scripts/powershell/create-new-feature.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -23,7 +23,7 @@ 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 " -DryRun Compute branch name without creating the branch" 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 " -Number N Specify branch number manually (overrides auto-detection)" diff --git a/templates/commands/specify.md b/templates/commands/specify.md index c1be1d0b5b..15c75ec396 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -96,9 +96,10 @@ Given that feature description, do this: - Persist the resolved path to `.specify/feature.json`: ```json { - "feature_directory": "SPECIFY_FEATURE_DIRECTORY" + "feature_directory": "" } ``` + Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`. This allows downstream commands (`/speckit.plan`, `/speckit.tasks`, etc.) to locate the feature directory without relying on git branch name conventions. **IMPORTANT**: diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index e835507d5e..1e23e35a7d 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -151,6 +151,53 @@ def test_shared_infra_skips_existing_files(self, tmp_path): assert (templates_dir / "plan-template.md").exists() +class TestForceExistingDirectory: + """Tests for --force merging into an existing named directory.""" + + def test_force_merges_into_existing_dir(self, tmp_path): + """specify init --force succeeds when the directory already exists.""" + from typer.testing import CliRunner + from specify_cli import app + + target = tmp_path / "existing-proj" + target.mkdir() + # Place a pre-existing file to verify it survives the merge + marker = target / "user-file.txt" + marker.write_text("keep me", encoding="utf-8") + + runner = CliRunner() + result = runner.invoke(app, [ + "init", str(target), "--integration", "copilot", "--force", + "--no-git", "--script", "sh", + ], catch_exceptions=False) + + assert result.exit_code == 0, f"init --force failed: {result.output}" + + # Pre-existing file should survive + assert marker.read_text(encoding="utf-8") == "keep me" + + # Spec Kit files should be installed + assert (target / ".specify" / "init-options.json").exists() + assert (target / ".specify" / "templates" / "spec-template.md").exists() + + def test_without_force_errors_on_existing_dir(self, tmp_path): + """specify init without --force errors when directory exists.""" + from typer.testing import CliRunner + from specify_cli import app + + target = tmp_path / "existing-proj" + target.mkdir() + + runner = CliRunner() + result = runner.invoke(app, [ + "init", str(target), "--integration", "copilot", + "--no-git", "--script", "sh", + ], catch_exceptions=False) + + assert result.exit_code == 1 + assert "already exists" in result.output + + class TestGitExtensionAutoInstall: """Tests for auto-installation of the git extension during specify init.""" diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index e692b30ada..2d2ee7c1a8 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -879,3 +879,56 @@ def test_fallback_to_branch_lookup(self, git_repo: Path): break else: pytest.fail("FEATURE_DIR not found in output") + + @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") + def test_ps_env_var_overrides_branch_lookup(self, git_repo: Path): + """PowerShell: SPECIFY_FEATURE_DIRECTORY env var takes priority.""" + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + custom_dir = git_repo / "my-custom-specs" / "ps-feature" + custom_dir.mkdir(parents=True) + + ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"' + result = subprocess.run( + ["pwsh", "-NoProfile", "-Command", ps_cmd], + cwd=git_repo, + capture_output=True, + text=True, + env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(custom_dir)}, + ) + 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(custom_dir) + break + else: + pytest.fail("FEATURE_DIR not found in PowerShell output") + + @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") + def test_ps_feature_json_overrides_branch_lookup(self, git_repo: Path): + """PowerShell: feature.json takes priority over branch-based lookup.""" + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + custom_dir = git_repo / "specs" / "ps-json-feature" + custom_dir.mkdir(parents=True) + + feature_json = git_repo / ".specify" / "feature.json" + feature_json.write_text( + f'{{"feature_directory": "{custom_dir}"}}\n', + encoding="utf-8", + ) + + ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"' + result = subprocess.run( + ["pwsh", "-NoProfile", "-Command", ps_cmd], + cwd=git_repo, + 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(custom_dir) + break + else: + pytest.fail("FEATURE_DIR not found in PowerShell output") From e5a2988e42d17d6085ff349639c6ebf99a8695b5 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:51:21 -0500 Subject: [PATCH 07/13] fix: normalize relative paths and fix Test-HasGit compat (#2117) - Bash common.sh: normalize SPECIFY_FEATURE_DIRECTORY and feature.json relative paths to absolute under repo root - PowerShell common.ps1: same normalization using IsPathRooted + Join-Path - PowerShell create-new-feature.ps1: call Test-HasGit without -RepoRoot for compatibility with core common.ps1 (no param) and git-common.ps1 (optional param with default) --- extensions/git/scripts/powershell/create-new-feature.ps1 | 4 +++- scripts/bash/common.sh | 4 ++++ scripts/powershell/common.ps1 | 8 ++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 index 291607d1bd..3e8b29372d 100644 --- a/extensions/git/scripts/powershell/create-new-feature.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -207,7 +207,9 @@ if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) { # Check if git is available if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) { - $hasGit = Test-HasGit -RepoRoot $repoRoot + # Call without parameters for compatibility with core common.ps1 (no -RepoRoot param) + # and git-common.ps1 (has -RepoRoot param with default). + $hasGit = Test-HasGit } else { try { git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index c38ed5f44e..98681febf6 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -201,6 +201,8 @@ get_feature_paths() { 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 local _fd if command -v jq >/dev/null 2>&1; then @@ -211,6 +213,8 @@ get_feature_paths() { fi 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 return 1 diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 3d94f6ef87..35ed884f0f 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -168,11 +168,19 @@ function Get-FeaturePathsEnv { $featureJson = Join-Path $repoRoot '.specify/feature.json' if ($env:SPECIFY_FEATURE_DIRECTORY) { $featureDir = $env:SPECIFY_FEATURE_DIRECTORY + # Normalize relative paths to absolute under repo root + if (-not [System.IO.Path]::IsPathRooted($featureDir)) { + $featureDir = Join-Path $repoRoot $featureDir + } } elseif (Test-Path $featureJson) { try { $featureConfig = Get-Content $featureJson -Raw | ConvertFrom-Json if ($featureConfig.feature_directory) { $featureDir = $featureConfig.feature_directory + # Normalize relative paths to absolute under repo root + if (-not [System.IO.Path]::IsPathRooted($featureDir)) { + $featureDir = Join-Path $repoRoot $featureDir + } } else { $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch } From 0dad208579c40b2832cc1fee29e25af08338ce75 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:06:51 -0500 Subject: [PATCH 08/13] test: add GIT_BRANCH_NAME automated tests for bash and PowerShell (#2117) - TestGitBranchNameOverrideBash: 5 tests (exact name, sequential prefix, timestamp prefix, overlong rejection, dry-run) - TestGitBranchNameOverridePowerShell: 4 tests (exact name, sequential prefix, timestamp prefix, overlong rejection) - Tests use extension scripts (not core) via new ext_git_repo and ext_ps_git_repo fixtures --- tests/test_timestamp_branches.py | 160 +++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 2d2ee7c1a8..6a3aa3a5ef 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -4,6 +4,7 @@ Converted from tests/test_timestamp_branches.sh so they are discovered by `uv run pytest`. """ +import json import os import re import shutil @@ -16,6 +17,8 @@ CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh" CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1" COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" +EXT_CREATE_FEATURE = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" +EXT_CREATE_FEATURE_PS = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" @pytest.fixture @@ -41,6 +44,62 @@ def git_repo(tmp_path: Path) -> Path: return tmp_path +@pytest.fixture +def ext_git_repo(tmp_path: Path) -> Path: + """Create a temp git repo with extension scripts (for GIT_BRANCH_NAME tests).""" + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=tmp_path, check=True) + # Extension script needs common.sh at .specify/scripts/bash/ + specify_scripts = tmp_path / ".specify" / "scripts" / "bash" + specify_scripts.mkdir(parents=True) + shutil.copy(COMMON_SH, specify_scripts / "common.sh") + # Also install core scripts for compatibility + core_scripts = tmp_path / "scripts" / "bash" + core_scripts.mkdir(parents=True) + shutil.copy(COMMON_SH, core_scripts / "common.sh") + # Copy extension script + ext_dir = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash" + ext_dir.mkdir(parents=True) + shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature.sh") + # Also copy git-common.sh if it exists + git_common = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + if git_common.exists(): + shutil.copy(git_common, ext_dir / "git-common.sh") + (tmp_path / ".specify" / "templates").mkdir(parents=True, exist_ok=True) + (tmp_path / "specs").mkdir(exist_ok=True) + return tmp_path + + +@pytest.fixture +def ext_ps_git_repo(tmp_path: Path) -> Path: + """Create a temp git repo with PowerShell extension scripts.""" + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=tmp_path, check=True) + # Install core PS scripts + ps_dir = tmp_path / "scripts" / "powershell" + ps_dir.mkdir(parents=True) + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + shutil.copy(common_ps, ps_dir / "common.ps1") + # Also install at .specify/scripts/powershell/ for extension resolution + specify_ps = tmp_path / ".specify" / "scripts" / "powershell" + specify_ps.mkdir(parents=True) + shutil.copy(common_ps, specify_ps / "common.ps1") + # Copy extension script + ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell" + ext_ps.mkdir(parents=True) + shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature.ps1") + git_common_ps = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1" + if git_common_ps.exists(): + shutil.copy(git_common_ps, ext_ps / "git-common.ps1") + (tmp_path / ".specify" / "templates").mkdir(parents=True, exist_ok=True) + (tmp_path / "specs").mkdir(exist_ok=True) + return tmp_path + + @pytest.fixture def no_git_dir(tmp_path: Path) -> Path: """Create a temp directory without git, but with scripts.""" @@ -776,6 +835,107 @@ def test_ps_dry_run_json_absent_without_flag(self, ps_git_repo: Path): assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}" +# ── GIT_BRANCH_NAME Override Tests ────────────────────────────────────────── + + +class TestGitBranchNameOverrideBash: + """Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh.""" + + def _run_ext(self, ext_git_repo: Path, env_extras: dict, *extra_args: str): + script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" + cmd = ["bash", str(script), "--json", *extra_args, "ignored"] + return subprocess.run(cmd, cwd=ext_git_repo, capture_output=True, text=True, + env={**os.environ, **env_extras}) + + def test_exact_name_no_prefix(self, ext_git_repo: Path): + """GIT_BRANCH_NAME is used verbatim with no numeric prefix added.""" + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "my-exact-branch"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "my-exact-branch" + assert data["FEATURE_NUM"] == "my-exact-branch" + + def test_sequential_prefix_extraction(self, ext_git_repo: Path): + """FEATURE_NUM extracted from sequential-style prefix (digits before dash).""" + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "042-custom-branch"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "042-custom-branch" + assert data["FEATURE_NUM"] == "042" + + def test_timestamp_prefix_extraction(self, ext_git_repo: Path): + """FEATURE_NUM extracted as full YYYYMMDD-HHMMSS for timestamp-style names.""" + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "20260407-143022-my-feature"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "20260407-143022-my-feature" + assert data["FEATURE_NUM"] == "20260407-143022" + + def test_overlong_name_rejected(self, ext_git_repo: Path): + """GIT_BRANCH_NAME exceeding 244 bytes is rejected with an error.""" + long_name = "a" * 245 + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": long_name}) + assert result.returncode != 0 + assert "244" in result.stderr + + def test_dry_run_with_override(self, ext_git_repo: Path): + """GIT_BRANCH_NAME works with --dry-run (no branch created).""" + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "dry-run-override"}, "--dry-run") + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "dry-run-override" + assert data.get("DRY_RUN") is True + branches = subprocess.run( + ["git", "branch", "--list", "dry-run-override"], + cwd=ext_git_repo, capture_output=True, text=True, + ) + assert "dry-run-override" not in branches.stdout + + +@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") +class TestGitBranchNameOverridePowerShell: + """Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.ps1.""" + + def _run_ext(self, ext_ps_git_repo: Path, env_extras: dict): + script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" + return subprocess.run( + ["pwsh", "-NoProfile", "-File", str(script), "-Json", "ignored"], + cwd=ext_ps_git_repo, capture_output=True, text=True, + env={**os.environ, **env_extras}, + ) + + def test_exact_name_no_prefix(self, ext_ps_git_repo: Path): + """GIT_BRANCH_NAME is used verbatim with no numeric prefix added.""" + result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "ps-exact-branch"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "ps-exact-branch" + assert data["FEATURE_NUM"] == "ps-exact-branch" + + def test_sequential_prefix_extraction(self, ext_ps_git_repo: Path): + """FEATURE_NUM extracted from sequential-style prefix.""" + result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "099-ps-numbered"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "099-ps-numbered" + assert data["FEATURE_NUM"] == "099" + + def test_timestamp_prefix_extraction(self, ext_ps_git_repo: Path): + """FEATURE_NUM extracted as full YYYYMMDD-HHMMSS for timestamp-style names.""" + result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "20260407-143022-ps-feature"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "20260407-143022-ps-feature" + assert data["FEATURE_NUM"] == "20260407-143022" + + def test_overlong_name_rejected(self, ext_ps_git_repo: Path): + """GIT_BRANCH_NAME exceeding 244 bytes is rejected.""" + long_name = "a" * 245 + result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": long_name}) + assert result.returncode != 0 + assert "244" in result.stderr + + # ── Feature Directory Resolution Tests ─────────────────────────────────────── From 358eef5148ee39a88299f05dd52194651df9eecf Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:39:26 -0500 Subject: [PATCH 09/13] fix: restore git init during specify init + review fixes (#2117) - Restore is_git_repo() and init_git_repo() functions removed in stage 2 - specify init now runs git init AND installs git extension (not just extension install alone) - Add is_dir() guard for non-here path to prevent uncontrolled error when target exists but is a file - Add python3 JSON fallback in common.sh for multi-line feature.json (grep pipeline fails on pretty-printed JSON without jq) --- scripts/bash/common.sh | 5 ++- src/specify_cli/__init__.py | 78 ++++++++++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 98681febf6..04af7d794f 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -207,8 +207,11 @@ get_feature_paths() { local _fd if command -v jq >/dev/null 2>&1; then _fd=$(jq -r '.feature_directory // empty' "$repo_root/.specify/feature.json" 2>/dev/null) + elif command -v python3 >/dev/null 2>&1; then + # Fallback: use Python to parse JSON so pretty-printed/multi-line files work + _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('feature_directory',''))" "$repo_root/.specify/feature.json" 2>/dev/null) else - # Minimal fallback: extract value with grep/sed when jq is unavailable + # Last resort: single-line grep fallback (won't work on multi-line JSON) _fd=$(grep -o '"feature_directory"[[:space:]]*:[[:space:]]*"[^"]*"' "$repo_root/.specify/feature.json" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/') fi if [[ -n "$_fd" ]]; then diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 106e0b9d5b..76163ea542 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -384,6 +384,53 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool: return found + +def is_git_repo(path: Path = None) -> bool: + """Check if the specified path is inside a git repository.""" + if path is None: + path = Path.cwd() + + if not path.is_dir(): + return False + + 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, Optional[str]]: + """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. @@ -959,6 +1006,9 @@ def init( project_path = Path(project_name).resolve() dir_existed_before = project_path.exists() if project_path.exists(): + if not project_path.is_dir(): + console.print(f"[red]Error:[/red] '{project_name}' exists but is not a directory.") + raise typer.Exit(1) existing_items = list(project_path.iterdir()) if force: if existing_items: @@ -1022,7 +1072,11 @@ 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) @@ -1132,22 +1186,36 @@ def init( if not no_git: tracker.start("git") + git_messages = [] + # Step 1: Initialize git repo if needed + 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_messages.append("init failed") + else: + git_messages.append("git not available") + # Step 2: Install bundled git extension try: from .extensions import ExtensionManager bundled_path = _locate_bundled_extension("git") if bundled_path: manager = ExtensionManager(project_path) if manager.registry.is_installed("git"): - tracker.skip("git", "git extension already installed") + git_messages.append("extension already installed") else: manager.install_from_directory( bundled_path, get_speckit_version() ) - tracker.complete("git", "git extension installed") + git_messages.append("extension installed") else: - tracker.skip("git", "bundled git extension not found") + git_messages.append("bundled extension not found") except Exception as ext_err: - tracker.error("git", f"install failed: {ext_err}") + git_messages.append(f"extension install failed: {ext_err}") + tracker.complete("git", "; ".join(git_messages)) else: tracker.skip("git", "--no-git flag") From 11d49196cb5f1f18e48db58ed72c1fdf5969793b Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:55:09 -0500 Subject: [PATCH 10/13] fix: use init_git_repo error_msg in failure output (#2117) --- src/specify_cli/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 76163ea542..e2d8926848 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1195,7 +1195,10 @@ def init( if success: git_messages.append("initialized") else: - git_messages.append("init failed") + if error_msg: + git_messages.append(f"init failed: {error_msg}") + else: + git_messages.append("init failed") else: git_messages.append("git not available") # Step 2: Install bundled git extension From 7ab238c5d23bffd73c1e56cd35c5067f8d144750 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:03:44 -0500 Subject: [PATCH 11/13] fix: ensure_executable_scripts also covers .specify/extensions/ (#2117) Extension .sh scripts (e.g. create-new-feature.sh, initialize-repo.sh) may lack execute bits after install. Scan both .specify/scripts/ and .specify/extensions/ for permission fixing. --- src/specify_cli/__init__.py | 64 ++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e2d8926848..2440e55b7b 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -700,41 +700,45 @@ def _install_shared_infra( def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None: - """Ensure POSIX .sh scripts under .specify/scripts (recursively) have execute bits (no-op on Windows).""" + """Ensure POSIX .sh scripts under .specify/scripts and .specify/extensions (recursively) have execute bits (no-op on Windows).""" if os.name == "nt": return # Windows: skip silently - scripts_root = project_path / ".specify" / "scripts" - if not scripts_root.is_dir(): - return + scan_roots = [ + project_path / ".specify" / "scripts", + project_path / ".specify" / "extensions", + ] failures: list[str] = [] updated = 0 - for script in scripts_root.rglob("*.sh"): - try: - if script.is_symlink() or not script.is_file(): - continue + for scripts_root in scan_roots: + if not scripts_root.is_dir(): + continue + for script in scripts_root.rglob("*.sh"): try: - with script.open("rb") as f: - if f.read(2) != b"#!": - continue - except Exception: - continue - st = script.stat() - mode = st.st_mode - if mode & 0o111: - continue - new_mode = mode - if mode & 0o400: - new_mode |= 0o100 - if mode & 0o040: - new_mode |= 0o010 - if mode & 0o004: - new_mode |= 0o001 - if not (new_mode & 0o100): - new_mode |= 0o100 - os.chmod(script, new_mode) - updated += 1 - except Exception as e: - failures.append(f"{script.relative_to(scripts_root)}: {e}") + if script.is_symlink() or not script.is_file(): + continue + try: + with script.open("rb") as f: + if f.read(2) != b"#!": + continue + except Exception: + continue + st = script.stat() + mode = st.st_mode + if mode & 0o111: + continue + new_mode = mode + if mode & 0o400: + new_mode |= 0o100 + if mode & 0o040: + new_mode |= 0o010 + if mode & 0o004: + new_mode |= 0o001 + if not (new_mode & 0o100): + new_mode |= 0o100 + os.chmod(script, new_mode) + updated += 1 + except Exception as e: + failures.append(f"{script.relative_to(project_path)}: {e}") if tracker: detail = f"{updated} updated" + (f", {len(failures)} failed" if failures else "") tracker.add("chmod", "Set script permissions recursively") From d81de9026e7954932ca42cc9cbf899ee011803cf Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:10:59 -0500 Subject: [PATCH 12/13] fix: move chmod after extension install + sanitize error_msg (#2117) - ensure_executable_scripts() now runs after git extension install so extension .sh files get execute bits in the same init run - Sanitize init_git_repo error_msg to single line (replace newlines, truncate to 120 chars) to prevent garbled StepTracker output --- src/specify_cli/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 2440e55b7b..56d84c0399 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1184,8 +1184,6 @@ def init( _install_shared_infra(project_path, selected_script, tracker=tracker) tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") - ensure_executable_scripts(project_path, tracker=tracker) - ensure_constitution_from_template(project_path, tracker=tracker) if not no_git: @@ -1199,8 +1197,10 @@ def init( if success: git_messages.append("initialized") else: + # Sanitize multi-line error_msg to single line for tracker if error_msg: - git_messages.append(f"init failed: {error_msg}") + sanitized = error_msg.replace('\n', ' ').strip() + git_messages.append(f"init failed: {sanitized[:120]}") else: git_messages.append("init failed") else: @@ -1226,6 +1226,9 @@ def init( else: tracker.skip("git", "--no-git flag") + # Fix permissions after all installs (scripts + extensions) + ensure_executable_scripts(project_path, tracker=tracker) + # Persist the CLI options so later operations (e.g. preset add) # can adapt their behaviour without re-scanning the filesystem. # Must be saved BEFORE preset install so _get_skills_dir() works. From 1416af067088256a563d558d7a7dd7242bf438ba Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:49:11 -0500 Subject: [PATCH 13/13] fix: use tracker.error for git init/extension failures (#2117) Git init failure and extension install failure were reported as tracker.complete (showing green) even on error. Now track a git_has_error flag and call tracker.error when any step fails, so the UI correctly reflects the failure state. --- src/specify_cli/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 56d84c0399..ff049058e7 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1189,6 +1189,7 @@ def init( if not no_git: tracker.start("git") git_messages = [] + git_has_error = False # Step 1: Initialize git repo if needed if is_git_repo(project_path): git_messages.append("existing repo detected") @@ -1197,6 +1198,7 @@ def init( if success: git_messages.append("initialized") else: + git_has_error = True # Sanitize multi-line error_msg to single line for tracker if error_msg: sanitized = error_msg.replace('\n', ' ').strip() @@ -1219,10 +1221,16 @@ def init( ) 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 git_messages.append(f"extension install failed: {ext_err}") - tracker.complete("git", "; ".join(git_messages)) + summary = "; ".join(git_messages) + if git_has_error: + tracker.error("git", summary) + else: + tracker.complete("git", summary) else: tracker.skip("git", "--no-git flag")