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")