diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e2ac3697f..ba4a7a6a8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ All notable changes to the Specify CLI and templates are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.24] - 2026-01-26 + +### Fixed + +- **Branch Name Truncation**: Fixed truncation logic in `create-new-feature.sh` and `create-new-feature.ps1` to correctly guarantee GitHub's 244-byte branch name limit. The calculation now properly computes the maximum allowed length for the `{short_name}` segment based on the template overhead after resolving `{number}`. +- **Interactive Settings Overwrite Prompt**: When `.specify/settings.toml` already exists and `--force` is not provided, the CLI now prompts with "Overwrite? [y/N]" instead of simply exiting. This aligns with standard CLI behavior for file overwrites. + +## [0.0.23] - 2026-01-23 + +### Added + +- **Customizable Branch Naming Templates**: Teams can now configure branch naming patterns via `.specify/settings.toml` + - New `branch.template` setting supports placeholders: `{number}`, `{short_name}`, `{username}`, `{email_prefix}` + - Per-user number scoping: When using `{username}` prefix, each team member gets their own independent number sequence + - Automatic username resolution from Git config with OS username fallback + - Examples: + - `"{number}-{short_name}"` → `001-add-login` (default, solo developer) + - `"{username}/{number}-{short_name}"` → `johndoe/001-add-login` (team) + - `"feature/{username}/{number}-{short_name}"` → `feature/johndoe/001-add-login` + - Full backward compatibility: Projects without settings files work identically to before +- **Settings File Generation**: New `specify init --settings` command generates a documented settings file + - Use `--force` to overwrite existing settings files + - Settings file includes comprehensive documentation and examples +- **Branch Name Validation**: Generated branch names are validated against Git naming rules before creation + - Validates against forbidden characters, path rules, and GitHub's 244-byte limit + - Clear error messages for invalid configurations + +### Changed + +- `create-new-feature.sh` and `create-new-feature.ps1` now source common utility functions +- Enhanced error messages for template configuration issues (FR-006) + ## [0.0.22] - 2025-11-07 - Support for VS Code/Copilot agents, and moving away from prompts to proper agents with hand-offs. diff --git a/README.md b/README.md index 76149512f6..30a8ca59fa 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ The `specify` command supports the following options: | `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) | | `--debug` | Flag | Enable detailed debug output for troubleshooting | | `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) | +| `--settings` | Flag | Generate `.specify/settings.toml` for branch template customization. Can be combined with other flags. | ### Examples @@ -238,10 +239,49 @@ specify init my-project --ai claude --debug # Use GitHub token for API requests (helpful for corporate environments) specify init my-project --ai claude --github-token ghp_your_token_here +# Generate settings file for branch template customization +specify init --settings + # Check system requirements specify check ``` +### Branch Template Configuration + +Teams can customize branch naming patterns by creating a `.specify/settings.toml` file: + +```bash +# Generate a settings file with documented options +specify init --settings +``` + +The settings file supports the following template variables: + +| Variable | Description | Example Output | +| ---------------- | -------------------------------------------------------------- | ----------------- | +| `{number}` | Auto-incrementing 3-digit feature number | `001`, `002` | +| `{short_name}` | Generated or provided short feature name | `add-login` | +| `{username}` | Git user.name, normalized (lowercase, hyphens) | `jane-smith` | +| `{email_prefix}` | Portion of Git user.email before the @ symbol | `jsmith` | + +**Example templates:** + +```toml +# Solo developer (default) +template = "{number}-{short_name}" +# Result: 001-add-login + +# Team with username prefix +template = "{username}/{number}-{short_name}" +# Result: johndoe/001-add-login + +# Team with feature prefix +template = "feature/{username}/{number}-{short_name}" +# Result: feature/johndoe/001-add-login +``` + +When using `{username}` or a static prefix, each prefix gets its own independent number sequence, avoiding conflicts between team members. + ### Available Slash Commands After running `specify init`, your AI coding agent will have access to these slash commands for structured development: diff --git a/docs/installation.md b/docs/installation.md index 6daff24315..eb53df62cb 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -62,6 +62,30 @@ If you prefer to get the templates without checking for the right tools: uvx --from git+https://github.com/github/spec-kit.git specify init --ai claude --ignore-agent-tools ``` +### Initialize Settings File + +Teams can customize branch naming patterns by initializing a settings file: + +```bash +# Settings file only (in current directory) +uvx --from git+https://github.com/github/spec-kit.git specify init --settings + +# Combined with full project initialization +uvx --from git+https://github.com/github/spec-kit.git specify init --settings --ai copilot + +# Or with --here flag for existing projects +uvx --from git+https://github.com/github/spec-kit.git specify init --here --settings --force +``` + +This creates `.specify/settings.toml` where you can configure the branch template: + +```toml +[branch] +template = "{username}/{number}-{short_name}" # e.g., jsmith/001-my-feature +``` + +Available template variables: `{number}`, `{short_name}`, `{username}`, `{email_prefix}` + ## Verification After initialization, you should see the following commands available in your AI agent: diff --git a/docs/quickstart.md b/docs/quickstart.md index 4d3b863b35..1dfe1968d3 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -29,6 +29,37 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --script sh # Force POSIX shell ``` +#### Customizing Branch Names + +Teams can customize how feature branches are named by initializing a settings file: + +```bash +# Settings file only (current directory) +uvx --from git+https://github.com/github/spec-kit.git specify init --settings + +# Combined with full project init +uvx --from git+https://github.com/github/spec-kit.git specify init --settings --ai copilot + +# Or with --here flag +uvx --from git+https://github.com/github/spec-kit.git specify init --here --settings --ai claude +``` + +This creates `.specify/settings.toml` where you can configure the branch template: + +```toml +[branch] +template = "{username}/{number}-{short_name}" # e.g., jsmith/001-my-feature +``` + +Available template variables: + +| Variable | Description | Example | +|----------|-------------|---------| +| `{number}` | Auto-incrementing 3-digit number | `001` | +| `{short_name}` | Kebab-case feature name | `my-feature` | +| `{username}` | Git user.name or OS username | `jsmith` | +| `{email_prefix}` | Part before @ in Git email | `john.smith` | + ### Step 2: Define Your Constitution **In your AI Agent's chat interface**, use the `/speckit.constitution` slash command to establish the core rules and principles for your project. You should provide your project's specific principles as arguments. diff --git a/docs/upgrade.md b/docs/upgrade.md index 676e5131f0..48b62609fb 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -61,6 +61,7 @@ These files are **never touched** by the upgrade—the template packages don't e - ✅ **Your specifications** (`specs/001-my-feature/spec.md`, etc.) - **CONFIRMED SAFE** - ✅ **Your implementation plans** (`specs/001-my-feature/plan.md`, `tasks.md`, etc.) - **CONFIRMED SAFE** +- ✅ **Your settings file** (`.specify/settings.toml`) - **CONFIRMED SAFE** - ✅ **Your source code** - **CONFIRMED SAFE** - ✅ **Your git history** - **CONFIRMED SAFE** diff --git a/pyproject.toml b/pyproject.toml index fb972adc7c..bdc1f94f7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.0.22" +version = "0.0.24" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 2c3165e41d..3bc0df0723 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -154,3 +154,205 @@ EOF check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } +# ============================================================================= +# TOML Settings Functions (for branch template customization) +# ============================================================================= + +# Parse a single key from a TOML file +# Usage: get_toml_value "file.toml" "branch.template" +# Supports dotted keys by searching within [section] blocks +get_toml_value() { + local file="$1" + local key="$2" + + [[ ! -f "$file" ]] && return 1 + + # Handle dotted keys like "branch.template" + if [[ "$key" == *.* ]]; then + local section="${key%%.*}" + local subkey="${key#*.}" + # Find the section and extract the key value + awk -v section="$section" -v key="$subkey" ' + BEGIN { in_section = 0 } + /^\[/ { + gsub(/[\[\]]/, "") + in_section = ($0 == section) + } + in_section && $0 ~ "^"key"[[:space:]]*=" { + sub(/^[^=]*=[[:space:]]*/, "") + gsub(/^"|"$/, "") # Remove surrounding quotes + print + exit + } + ' "$file" + else + # Simple key without section + grep -E "^${key}[[:space:]]*=" "$file" 2>/dev/null | \ + sed 's/.*=[[:space:]]*"\([^"]*\)".*/\1/' | head -1 + fi +} + +# Load branch template from settings file +# Returns: template string or empty if not found +load_branch_template() { + local repo_root="${1:-$(get_repo_root)}" + local settings_file="$repo_root/.specify/settings.toml" + + if [[ -f "$settings_file" ]]; then + get_toml_value "$settings_file" "branch.template" + fi +} + +# ============================================================================= +# Username and Email Resolution Functions +# ============================================================================= + +# Resolve {username} variable from Git config or OS fallback +# Returns: normalized username (lowercase, hyphens for special chars) +resolve_username() { + local username + username=$(git config user.name 2>/dev/null || echo "") + + if [[ -z "$username" ]]; then + # Fallback to OS username + username="${USER:-${USERNAME:-unknown}}" + fi + + # Normalize: lowercase, replace non-alphanumeric with hyphens, collapse multiple hyphens + echo "$username" | tr '[:upper:]' '[:lower:]' | \ + sed 's/[^a-z0-9]/-/g' | \ + sed 's/-\+/-/g' | \ + sed 's/^-//' | \ + sed 's/-$//' +} + +# Resolve {email_prefix} variable from Git config +# Returns: email prefix (portion before @) or empty string +resolve_email_prefix() { + local email + email=$(git config user.email 2>/dev/null || echo "") + + if [[ -n "$email" && "$email" == *@* ]]; then + echo "${email%%@*}" | tr '[:upper:]' '[:lower:]' + fi + # Returns empty string if no email configured (per FR-002 clarification) +} + +# ============================================================================= +# Branch Name Validation Functions +# ============================================================================= + +# Validate branch name against Git rules +# Args: $1 = branch name +# Returns: 0 if valid, 1 if invalid (prints error to stderr) +validate_branch_name() { + local name="$1" + + # Cannot be empty + if [[ -z "$name" ]]; then + echo "Error: Branch name cannot be empty" >&2 + return 1 + fi + + # Cannot start with hyphen + if [[ "$name" == -* ]]; then + echo "Error: Branch name cannot start with hyphen: $name" >&2 + return 1 + fi + + # Cannot contain .. + if [[ "$name" == *..* ]]; then + echo "Error: Branch name cannot contain '..': $name" >&2 + return 1 + fi + + # Cannot contain forbidden characters: ~ ^ : ? * [ \ + if [[ "$name" =~ [~\^:\?\*\[\\] ]]; then + echo "Error: Branch name contains invalid characters (~^:?*[\\): $name" >&2 + return 1 + fi + + # Cannot end with .lock + if [[ "$name" == *.lock ]]; then + echo "Error: Branch name cannot end with '.lock': $name" >&2 + return 1 + fi + + # Cannot end with / + if [[ "$name" == */ ]]; then + echo "Error: Branch name cannot end with '/': $name" >&2 + return 1 + fi + + # Cannot contain // + if [[ "$name" == *//* ]]; then + echo "Error: Branch name cannot contain '//': $name" >&2 + return 1 + fi + + # Check max length (244 bytes for GitHub) + if [[ ${#name} -gt 244 ]]; then + echo "Warning: Branch name exceeds 244 bytes (GitHub limit): $name" >&2 + # Return success but warn - truncation handled elsewhere + fi + + return 0 +} + +# ============================================================================= +# Per-User Number Scoping Functions +# ============================================================================= + +# Get highest feature number for a specific prefix pattern +# Args: $1 = prefix (e.g., "johndoe/" or "feature/johndoe/") +# Returns: highest number found (0 if none) +get_highest_for_prefix() { + local prefix="$1" + local repo_root="${2:-$(get_repo_root)}" + local specs_dir="$repo_root/specs" + local highest=0 + + # Escape special regex characters in prefix for grep + local escaped_prefix + escaped_prefix=$(printf '%s' "$prefix" | sed 's/[.[\*^$()+?{|\\]/\\&/g') + + # Check specs directory for matching directories + if [[ -d "$specs_dir" ]]; then + for dir in "$specs_dir"/"${prefix}"*; do + [[ -d "$dir" ]] || continue + local dirname + dirname=$(basename "$dir") + # Extract number after prefix: prefix + 3-digit number + if [[ "$dirname" =~ ^${escaped_prefix}([0-9]{3})- ]]; then + local num=$((10#${BASH_REMATCH[1]})) + if [[ "$num" -gt "$highest" ]]; then + highest=$num + fi + fi + done + fi + + # Also check git branches if available + if git rev-parse --show-toplevel >/dev/null 2>&1; then + local branches + branches=$(git branch -a 2>/dev/null || echo "") + if [[ -n "$branches" ]]; then + while IFS= read -r branch; do + # Clean branch name + local clean_branch + clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||') + + # Check if branch matches prefix pattern + if [[ "$clean_branch" =~ ^${escaped_prefix}([0-9]{3})- ]]; then + local num=$((10#${BASH_REMATCH[1]})) + if [[ "$num" -gt "$highest" ]]; then + highest=$num + fi + fi + done <<< "$branches" + fi + fi + + echo "$highest" +} + diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index c40cfd77f0..5c6d09ee73 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -2,6 +2,10 @@ set -e +# Source common functions for TOML parsing and template resolution +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + JSON_MODE=false SHORT_NAME="" BRANCH_NUMBER="" @@ -234,10 +238,55 @@ else BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") fi -# Determine branch number +# ============================================================================= +# Template-based branch naming (FR-001, FR-002, FR-007, FR-008) +# ============================================================================= + +# Load branch template from settings file (defaults to {number}-{short_name}) +BRANCH_TEMPLATE=$(load_branch_template "$REPO_ROOT") +if [ -z "$BRANCH_TEMPLATE" ]; then + BRANCH_TEMPLATE="{number}-{short_name}" +fi + +# Validate template contains required placeholders +if [[ "$BRANCH_TEMPLATE" != *"{number}"* ]]; then + echo "Error: Template must contain {number} placeholder: $BRANCH_TEMPLATE" >&2 + exit 1 +fi +if [[ "$BRANCH_TEMPLATE" != *"{short_name}"* ]]; then + echo "Error: Template must contain {short_name} placeholder: $BRANCH_TEMPLATE" >&2 + exit 1 +fi + +# Resolve template variables (except {number} and {short_name}) +RESOLVED_TEMPLATE="$BRANCH_TEMPLATE" + +# Resolve {username} if present +if [[ "$RESOLVED_TEMPLATE" == *"{username}"* ]]; then + USERNAME_VALUE=$(resolve_username) + RESOLVED_TEMPLATE="${RESOLVED_TEMPLATE//\{username\}/$USERNAME_VALUE}" +fi + +# Resolve {email_prefix} if present +if [[ "$RESOLVED_TEMPLATE" == *"{email_prefix}"* ]]; then + EMAIL_PREFIX_VALUE=$(resolve_email_prefix) + if [ -z "$EMAIL_PREFIX_VALUE" ]; then + echo "Warning: {email_prefix} used but no Git email configured" >&2 + fi + RESOLVED_TEMPLATE="${RESOLVED_TEMPLATE//\{email_prefix\}/$EMAIL_PREFIX_VALUE}" +fi + +# Extract prefix (everything before {number}) for per-user number scoping +PREFIX_PATTERN="${RESOLVED_TEMPLATE%%\{number\}*}" + +# Determine branch number using prefix-scoped lookup (FR-008) if [ -z "$BRANCH_NUMBER" ]; then - if [ "$HAS_GIT" = true ]; then - # Check existing branches on remotes + if [ -n "$PREFIX_PATTERN" ]; then + # Use prefix-scoped number lookup + HIGHEST=$(get_highest_for_prefix "$PREFIX_PATTERN" "$REPO_ROOT") + BRANCH_NUMBER=$((HIGHEST + 1)) + elif [ "$HAS_GIT" = true ]; then + # No prefix - check all existing branches on remotes BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") else # Fall back to local directory check @@ -248,23 +297,46 @@ fi # Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") -BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + +# Complete template resolution +BRANCH_NAME="${RESOLVED_TEMPLATE//\{number\}/$FEATURE_NUM}" +BRANCH_NAME="${BRANCH_NAME//\{short_name\}/$BRANCH_SUFFIX}" + +# Validate the final branch name (FR-004) +if ! validate_branch_name "$BRANCH_NAME"; then + echo "Error: Generated branch name is invalid: $BRANCH_NAME" >&2 + exit 1 +fi # GitHub enforces a 244-byte limit on branch names # Validate and truncate if necessary MAX_BRANCH_LENGTH=244 if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then - # Calculate how much we need to trim from suffix - # Account for: feature number (3) + hyphen (1) = 4 chars - MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - 4)) + ORIGINAL_BRANCH_NAME="$BRANCH_NAME" + + # For template-based names, truncate the short_name portion + # Compute template overhead: resolve {number} to the 3-digit feature number, + # then calculate the fixed portion length by removing {short_name} placeholder + TEMPLATE_WITH_NUMBER="${RESOLVED_TEMPLATE//\{number\}/$FEATURE_NUM}" + TEMPLATE_OVERHEAD=$((${#TEMPLATE_WITH_NUMBER} - 12)) # 12 = length of "{short_name}" placeholder - # Truncate suffix at word boundary if possible - TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) - # Remove trailing hyphen if truncation created one + # Maximum allowed length for short_name = MAX_BRANCH_LENGTH - fixed template overhead + MAX_SHORT_NAME_LENGTH=$((MAX_BRANCH_LENGTH - TEMPLATE_OVERHEAD)) + + # Cap to current suffix length (don't expand) and ensure minimum + if [ $MAX_SHORT_NAME_LENGTH -gt ${#BRANCH_SUFFIX} ]; then + MAX_SHORT_NAME_LENGTH=${#BRANCH_SUFFIX} + fi + if [ $MAX_SHORT_NAME_LENGTH -lt 10 ]; then + MAX_SHORT_NAME_LENGTH=10 # Minimum reasonable short_name length + fi + + TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SHORT_NAME_LENGTH) TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') - ORIGINAL_BRANCH_NAME="$BRANCH_NAME" - BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + # Re-resolve branch name with truncated suffix + BRANCH_NAME="${RESOLVED_TEMPLATE//\{number\}/$FEATURE_NUM}" + BRANCH_NAME="${BRANCH_NAME//\{short_name\}/$TRUNCATED_SUFFIX}" >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index b0be273545..abe862f755 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -135,3 +135,284 @@ function Test-DirHasFiles { } } +# ============================================================================= +# TOML Settings Functions (for branch template customization) +# ============================================================================= + +<# +.SYNOPSIS + Parse a single key from a TOML file +.DESCRIPTION + Extracts a value from a TOML file, supporting dotted keys like "branch.template" +.PARAMETER File + Path to the TOML file +.PARAMETER Key + Key to extract (supports dotted notation like "branch.template") +.EXAMPLE + Get-TomlValue -File ".specify/settings.toml" -Key "branch.template" +#> +function Get-TomlValue { + param( + [Parameter(Mandatory = $true)] + [string]$File, + [Parameter(Mandatory = $true)] + [string]$Key + ) + + if (-not (Test-Path $File)) { + return $null + } + + $content = Get-Content $File -Raw -ErrorAction SilentlyContinue + if (-not $content) { + return $null + } + + # Handle dotted keys like "branch.template" + if ($Key -match '\.') { + $parts = $Key -split '\.', 2 + $section = $parts[0] + $subkey = $parts[1] + + # Find the section and extract the key + $inSection = $false + foreach ($line in ($content -split "`n")) { + $line = $line.Trim() + + # Check for section header + if ($line -match '^\[([^\]]+)\]$') { + $inSection = ($matches[1] -eq $section) + continue + } + + # If in the right section, look for the key + if ($inSection -and $line -match "^$subkey\s*=\s*`"([^`"]*)`"") { + return $matches[1] + } + } + } + else { + # Simple key without section + if ($content -match "$Key\s*=\s*`"([^`"]*)`"") { + return $matches[1] + } + } + + return $null +} + +<# +.SYNOPSIS + Load branch template from settings file +.PARAMETER RepoRoot + Repository root path (defaults to current repo root) +.RETURNS + Template string or $null if not found +#> +function Get-BranchTemplate { + param( + [string]$RepoRoot = (Get-RepoRoot) + ) + + $settingsFile = Join-Path $RepoRoot '.specify/settings.toml' + + if (Test-Path $settingsFile) { + return Get-TomlValue -File $settingsFile -Key 'branch.template' + } + + return $null +} + +# ============================================================================= +# Username and Email Resolution Functions +# ============================================================================= + +<# +.SYNOPSIS + Resolve {username} variable from Git config or OS fallback +.RETURNS + Normalized username (lowercase, hyphens for special chars) +#> +function Resolve-Username { + $username = $null + + try { + $username = git config user.name 2>$null + } + catch { + # Git command failed + } + + if (-not $username) { + # Fallback to OS username + $username = $env:USERNAME + if (-not $username) { + $username = $env:USER + } + if (-not $username) { + $username = 'unknown' + } + } + + # Normalize: lowercase, replace non-alphanumeric with hyphens, collapse multiple hyphens + $normalized = $username.ToLower() -replace '[^a-z0-9]', '-' -replace '-+', '-' -replace '^-', '' -replace '-$', '' + return $normalized +} + +<# +.SYNOPSIS + Resolve {email_prefix} variable from Git config +.RETURNS + Email prefix (portion before @) or empty string +#> +function Resolve-EmailPrefix { + $email = $null + + try { + $email = git config user.email 2>$null + } + catch { + # Git command failed + } + + if ($email -and $email -match '^([^@]+)@') { + return $matches[1].ToLower() + } + + # Returns empty string if no email configured (per FR-002 clarification) + return '' +} + +# ============================================================================= +# Branch Name Validation Functions +# ============================================================================= + +<# +.SYNOPSIS + Validate branch name against Git rules +.PARAMETER Name + Branch name to validate +.RETURNS + $true if valid, $false if invalid (writes error to host) +#> +function Test-BranchName { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + # Cannot be empty + if (-not $Name) { + Write-Error "Error: Branch name cannot be empty" + return $false + } + + # Cannot start with hyphen + if ($Name -match '^-') { + Write-Error "Error: Branch name cannot start with hyphen: $Name" + return $false + } + + # Cannot contain .. + if ($Name -match '\.\.') { + Write-Error "Error: Branch name cannot contain '..': $Name" + return $false + } + + # Cannot contain forbidden characters: ~ ^ : ? * [ \ + if ($Name -match '[~\^:\?\*\[\\]') { + Write-Error "Error: Branch name contains invalid characters (~^:?*[\): $Name" + return $false + } + + # Cannot end with .lock + if ($Name -match '\.lock$') { + Write-Error "Error: Branch name cannot end with '.lock': $Name" + return $false + } + + # Cannot end with / + if ($Name -match '/$') { + Write-Error "Error: Branch name cannot end with '/': $Name" + return $false + } + + # Cannot contain // + if ($Name -match '//') { + Write-Error "Error: Branch name cannot contain '//': $Name" + return $false + } + + # Check max length (244 bytes for GitHub) + if ($Name.Length -gt 244) { + Write-Warning "Warning: Branch name exceeds 244 bytes (GitHub limit): $Name" + # Return success but warn - truncation handled elsewhere + } + + return $true +} + +# ============================================================================= +# Per-User Number Scoping Functions +# ============================================================================= + +<# +.SYNOPSIS + Get highest feature number for a specific prefix pattern +.PARAMETER Prefix + Prefix to match (e.g., "johndoe/" or "feature/johndoe/") +.PARAMETER RepoRoot + Repository root path (defaults to current repo root) +.RETURNS + Highest number found (0 if none) +#> +function Get-HighestNumberForPrefix { + param( + [Parameter(Mandatory = $true)] + [string]$Prefix, + [string]$RepoRoot = (Get-RepoRoot) + ) + + $specsDir = Join-Path $RepoRoot 'specs' + $highest = 0 + + # Escape special regex characters in prefix + $escapedPrefix = [regex]::Escape($Prefix) + + # Check specs directory for matching directories + if (Test-Path $specsDir) { + Get-ChildItem -Path $specsDir -Directory | ForEach-Object { + if ($_.Name -match "^$escapedPrefix(\d{3})-") { + $num = [int]$matches[1] + if ($num -gt $highest) { + $highest = $num + } + } + } + } + + # Also check git branches if available + try { + $branches = git branch -a 2>$null + if ($LASTEXITCODE -eq 0 -and $branches) { + foreach ($branch in $branches) { + # Clean branch name + $cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' + + # Check if branch matches prefix pattern + if ($cleanBranch -match "^$escapedPrefix(\d{3})-") { + $num = [int]$matches[1] + if ($num -gt $highest) { + $highest = $num + } + } + } + } + } + catch { + # Git not available or command failed + } + + return $highest +} + diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 2f0172e35d..cd5df25ecb 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -11,6 +11,9 @@ param( ) $ErrorActionPreference = 'Stop' +# Source common functions for TOML parsing and template resolution +. (Join-Path $PSScriptRoot 'common.ps1') + # Show help if requested if ($Help) { Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] [-Number N] " @@ -206,10 +209,58 @@ if ($ShortName) { $branchSuffix = Get-BranchName -Description $featureDesc } -# Determine branch number +# ============================================================================= +# Template-based branch naming (FR-001, FR-002, FR-007, FR-008) +# ============================================================================= + +# Load branch template from settings file (defaults to {number}-{short_name}) +$branchTemplate = Get-BranchTemplate -RepoRoot $repoRoot +if (-not $branchTemplate) { + $branchTemplate = '{number}-{short_name}' +} + +# Validate template contains required placeholders +if ($branchTemplate -notmatch '\{number\}') { + Write-Error "Error: Template must contain {number} placeholder: $branchTemplate" + exit 1 +} +if ($branchTemplate -notmatch '\{short_name\}') { + Write-Error "Error: Template must contain {short_name} placeholder: $branchTemplate" + exit 1 +} + +# Resolve template variables (except {number} and {short_name}) +$resolvedTemplate = $branchTemplate + +# Resolve {username} if present +if ($resolvedTemplate -match '\{username\}') { + $usernameValue = Resolve-Username + $resolvedTemplate = $resolvedTemplate -replace '\{username\}', $usernameValue +} + +# Resolve {email_prefix} if present +if ($resolvedTemplate -match '\{email_prefix\}') { + $emailPrefixValue = Resolve-EmailPrefix + if (-not $emailPrefixValue) { + Write-Warning 'Warning: {email_prefix} used but no Git email configured' + } + $resolvedTemplate = $resolvedTemplate -replace '\{email_prefix\}', $emailPrefixValue +} + +# Extract prefix (everything before {number}) for per-user number scoping +$prefixPattern = '' +if ($resolvedTemplate -match '^(.*?)\{number\}') { + $prefixPattern = $matches[1] +} + +# Determine branch number using prefix-scoped lookup (FR-008) if ($Number -eq 0) { - if ($hasGit) { - # Check existing branches on remotes + if ($prefixPattern) { + # Use prefix-scoped number lookup + $highest = Get-HighestNumberForPrefix -Prefix $prefixPattern -RepoRoot $repoRoot + $Number = $highest + 1 + } elseif ($hasGit) { + # No prefix - check all existing branches on remotes $Number = Get-NextBranchNumber -SpecsDir $specsDir } else { # Fall back to local directory check @@ -218,23 +269,46 @@ if ($Number -eq 0) { } $featureNum = ('{0:000}' -f $Number) -$branchName = "$featureNum-$branchSuffix" + +# Complete template resolution +$branchName = $resolvedTemplate -replace '\{number\}', $featureNum +$branchName = $branchName -replace '\{short_name\}', $branchSuffix + +# Validate the final branch name (FR-004) +if (-not (Test-BranchName -Name $branchName)) { + Write-Error "Error: Generated branch name is invalid: $branchName" + exit 1 +} # GitHub enforces a 244-byte limit on branch names # Validate and truncate if necessary $maxBranchLength = 244 if ($branchName.Length -gt $maxBranchLength) { - # Calculate how much we need to trim from suffix - # Account for: feature number (3) + hyphen (1) = 4 chars - $maxSuffixLength = $maxBranchLength - 4 + $originalBranchName = $branchName + + # For template-based names, truncate the short_name portion + # Compute template overhead: resolve {number} to the 3-digit feature number, + # then calculate the fixed portion length by removing {short_name} placeholder + $templateWithNumber = $resolvedTemplate -replace '\{number\}', $featureNum + $templateOverhead = $templateWithNumber.Length - '{short_name}'.Length - # Truncate suffix - $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) - # Remove trailing hyphen if truncation created one + # Maximum allowed length for short_name = maxBranchLength - fixed template overhead + $maxShortNameLength = $maxBranchLength - $templateOverhead + + # Cap to current suffix length (don't expand) and ensure minimum + if ($maxShortNameLength -gt $branchSuffix.Length) { + $maxShortNameLength = $branchSuffix.Length + } + if ($maxShortNameLength -lt 10) { + $maxShortNameLength = 10 # Minimum reasonable short_name length + } + + $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxShortNameLength)) $truncatedSuffix = $truncatedSuffix -replace '-$', '' - $originalBranchName = $branchName - $branchName = "$featureNum-$truncatedSuffix" + # Re-resolve branch name with truncated suffix + $branchName = $resolvedTemplate -replace '\{number\}', $featureNum + $branchName = $branchName -replace '\{short_name\}', $truncatedSuffix Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1dedb31949..d04e84bb2c 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -942,6 +942,84 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = for f in failures: console.print(f" - {f}") + +# Settings file template content for branch template customization +SETTINGS_TEMPLATE = '''# Spec Kit Settings +# Documentation: https://github.github.io/spec-kit/ +# +# This file configures project-level settings for the Spec Kit workflow. +# Place this file at .specify/settings.toml in your project root. + +[branch] +# Template for generating feature branch names. +# +# Available variables: +# {number} - Auto-incrementing 3-digit feature number (001, 002, ...) +# {short_name} - Generated or provided short feature name +# {username} - Git user.name, normalized for branch names (lowercase, hyphens) +# {email_prefix} - Portion of Git user.email before the @ symbol +# +# Examples: +# "{number}-{short_name}" # 001-add-login (default, solo dev) +# "{username}/{number}-{short_name}" # johndoe/001-add-login (team) +# "feature/{username}/{number}-{short_name}" # feature/johndoe/001-add-login +# "users/{email_prefix}/{number}-{short_name}" # users/jsmith/001-add-login +# +# When using {username} or a static prefix, each prefix gets its own number +# sequence to avoid conflicts between team members. +# +# IMPORTANT: Template MUST contain both {number} and {short_name} placeholders. + +template = "{number}-{short_name}" +''' + + +def _init_settings_file(project_path: Path = None, force: bool = False) -> None: + """Generate a settings file with branch template configuration. + + This is called when `specify init --settings` is used. + Creates .specify/settings.toml with documented template options. + + Args: + project_path: Path to the project directory. Defaults to current directory. + force: If True, overwrite existing settings file without prompting. + """ + if project_path is None: + project_path = Path.cwd() + + settings_dir = project_path / ".specify" + settings_file = settings_dir / "settings.toml" + + # Check if settings file already exists + if settings_file.exists(): + if force: + console.print(f"[yellow]Overwriting existing settings file:[/yellow] {settings_file}") + else: + console.print(f"[yellow]Settings file already exists:[/yellow] {settings_file}") + response = typer.confirm("Overwrite?", default=False) + if not response: + console.print("[yellow]Operation cancelled[/yellow]") + raise typer.Exit(0) + + # Create .specify directory if it doesn't exist + try: + settings_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + console.print(f"[red]Error:[/red] Could not create directory {settings_dir}: {e}") + raise typer.Exit(2) + + # Write the settings file + try: + settings_file.write_text(SETTINGS_TEMPLATE) + except Exception as e: + console.print(f"[red]Error:[/red] Could not write settings file: {e}") + raise typer.Exit(2) + + # Success message per contracts/cli.md + console.print(f"[green]✓[/green] Created settings file: [cyan]{settings_file}[/cyan]") + console.print(" Edit the [cyan]branch.template[/cyan] setting to customize branch naming.") + + @app.command() def init( project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), @@ -950,10 +1028,11 @@ def init( ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"), no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"), here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"), - force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"), + force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here or --settings (skip confirmation)"), skip_tls: bool = typer.Option(False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)"), debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"), github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"), + settings: bool = typer.Option(False, "--settings", help="Generate a settings file with branch template configuration instead of full project initialization"), ): """ Initialize a new Specify project from the latest template. @@ -966,6 +1045,8 @@ def init( 5. Initialize a fresh git repository (if not --no-git and no existing repo) 6. Optionally set up AI assistant commands + Use --settings to generate only a settings file for branch template customization. + Examples: specify init my-project specify init my-project --ai claude @@ -978,8 +1059,17 @@ def init( specify init --here --ai codebuddy specify init --here specify init --here --force # Skip confirmation when current directory not empty + specify init --settings # Generate settings file in current directory + specify init --settings --force # Overwrite existing settings file + specify init --here --settings # Full init plus settings file + specify init my-project --settings --ai claude # New project with settings """ + # Handle --settings mode without other arguments: generate settings file only in current directory + if settings and not here and not project_name: + _init_settings_file(project_path=Path.cwd(), force=force) + return + show_banner() if project_name == ".": @@ -1166,6 +1256,11 @@ def init( console.print(tracker.render()) console.print("\n[bold green]Project ready.[/bold green]") + # Create settings file if --settings flag was passed + if settings: + console.print() + _init_settings_file(project_path=project_path, force=force) + # Show git error details if initialization failed if git_error_message: console.print() diff --git a/templates/settings.toml b/templates/settings.toml new file mode 100644 index 0000000000..de597a30a1 --- /dev/null +++ b/templates/settings.toml @@ -0,0 +1,27 @@ +# Spec Kit Settings +# Documentation: https://github.github.io/spec-kit/ +# +# This file configures project-level settings for the Spec Kit workflow. +# Place this file at .specify/settings.toml in your project root. + +[branch] +# Template for generating feature branch names. +# +# Available variables: +# {number} - Auto-incrementing 3-digit feature number (001, 002, ...) +# {short_name} - Generated or provided short feature name +# {username} - Git user.name, normalized for branch names (lowercase, hyphens) +# {email_prefix} - Portion of Git user.email before the @ symbol +# +# Examples: +# "{number}-{short_name}" # 001-add-login (default, solo dev) +# "{username}/{number}-{short_name}" # johndoe/001-add-login (team) +# "feature/{username}/{number}-{short_name}" # feature/johndoe/001-add-login +# "users/{email_prefix}/{number}-{short_name}" # users/jsmith/001-add-login +# +# When using {username} or a static prefix, each prefix gets its own number +# sequence to avoid conflicts between team members. +# +# IMPORTANT: Template MUST contain both {number} and {short_name} placeholders. + +template = "{number}-{short_name}"