diff --git a/docs/quickstart.md b/docs/quickstart.md index fb2ecc2f74..33fa25c9cc 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -10,7 +10,7 @@ Commands are shown here in `/speckit.*` form, but the exact invocation depends o ## Recommended Process > [!TIP] -> **Context Awareness**: Spec Kit tracks the active feature by the feature directory recorded in `.specify/feature.json` (overridable with the `SPECIFY_FEATURE_DIRECTORY` environment variable). Commands resolve the feature from that state, **not** from the checked-out Git branch — no Git required. The opt-in **git** extension adds numbered feature branches (e.g. `001-feature-name`) for organizing work in version control, but the active feature is still whichever directory that state points to; `git checkout` alone does not change it. To point commands at a different feature, update `.specify/feature.json` (or set `SPECIFY_FEATURE_DIRECTORY`). +> **Context Awareness**: Spec Kit tracks the active feature by the feature directory recorded in `.specify/feature.json` (overridable with the `SPECIFY_FEATURE_DIRECTORY` environment variable). Commands resolve the feature from that state, **not** from the checked-out Git branch — no Git required. Set a per-feature `FEATURE_ID` such as `ENHANCEMENT-XYZ` when invoking `/speckit.specify` to replace the default numeric/timestamp prefix; it is normalized to lowercase for the directory and branch. The opt-in **git** extension uses the same identifier for its branch when enabled. The active feature is still whichever directory `.specify/feature.json` points to; `git checkout` alone does not change it. After installing Spec Kit, each command below is a step in the process. Two paths are common: diff --git a/docs/reference/core.md b/docs/reference/core.md index fdf0b80e7f..dc4374a6f6 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -52,6 +52,7 @@ specify init my-project --integration copilot --preset compliance | ----------------- | ------------------------------------------------------------------------ | | `SPECKIT_INTEGRATION_DEFAULT` | Override the fallback integration used by `specify init` when `--integration` is omitted (interactive prompt default and non-interactive fallback). Set it to any registered integration key (e.g. `gemini`, `claude`). An unrecognized value is ignored with a warning and the built-in default (`copilot`) is used. An explicit `--integration ` always takes precedence. | | `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing* `.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory. Resolved once in the core root helper (`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell), so it is honored by the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …) and the Git extension's feature-branch creation, which inherit it. The `specify` CLI applies the **same** validation rules to every project-scoped subcommand (`specify integration …`, `specify extension …`, `specify workflow …`, `specify preset …`, and the rest that operate on a `.specify/` project), so those can target a member project too. When unset, Bash/PowerShell helpers keep their existing upward search; the `specify` CLI keeps its project-scoped resolver cwd-only unless a command explicitly defines broader detection (for example, bundle commands). | +| `FEATURE_ID` | Use a per-feature custom identifier instead of the configured sequential or timestamp prefix. Values are normalized to lowercase, must start and end with a letter or digit, and may contain letters, digits, dots, underscores, and hyphens. For example, `FEATURE_ID=ENHANCEMENT-XYZ` produces `specs/enhancement-xyz-` and, when the Git extension is enabled, a matching branch prefix. | | `SPECIFY_FEATURE_DIRECTORY` | Override the active feature directory *within* the resolved project (takes precedence over `.specify/feature.json`). Relative paths resolve under the project root. Combine with `SPECIFY_INIT_DIR` to pick both the project and the feature non-interactively. | | `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. | diff --git a/extensions/catalog.json b/extensions/catalog.json index d05c48e0e5..8e0058e7e1 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -51,8 +51,8 @@ "git": { "name": "Git Branching Workflow", "id": "git", - "version": "1.0.0", - "description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection", + "version": "1.0.1", + "description": "Feature branch creation with custom identifiers or sequential/timestamp prefixes, validation, and Git remote detection", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", "bundled": true, diff --git a/extensions/git/README.md b/extensions/git/README.md index c0cb7b5d00..b543789774 100644 --- a/extensions/git/README.md +++ b/extensions/git/README.md @@ -1,13 +1,13 @@ # Git Branching Workflow Extension -Git repository initialization, feature branch creation, numbering (sequential/timestamp), validation, remote detection, and auto-commit for Spec Kit. +Git repository initialization, feature branch creation, custom identifiers or numbering, validation, remote detection, and auto-commit for Spec Kit. ## Overview This extension provides Git operations as an optional, self-contained module. It manages: - **Repository initialization** with configurable commit messages -- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering and optional templates for branch namespaces +- **Feature branch creation** with custom identifiers (`enhancement-xyz-feature-name`), sequential numbering (`001-feature-name`), or timestamps (`20260319-143022-feature-name`) and optional templates for branch namespaces - **Branch validation** to ensure branches follow naming conventions - **Git remote detection** for GitHub integration (e.g., issue creation) - **Auto-commit** after core commands (configurable per-command with custom messages, or Conventional Commit messages generated by the agent) @@ -17,7 +17,7 @@ This extension provides Git operations as an optional, self-contained module. It | Command | Description | |---------|-------------| | `speckit.git.initialize` | Initialize a Git repository with a configurable commit message | -| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering | +| `speckit.git.feature` | Create a feature branch with a custom identifier, sequential number, or timestamp | | `speckit.git.validate` | Validate current branch follows feature branch naming conventions | | `speckit.git.remote` | Detect Git remote URL for GitHub integration | | `speckit.git.commit` | Auto-commit changes (configurable per-command enable/disable and messages) | @@ -56,6 +56,7 @@ branch_numbering: sequential # Optional branch name template. Leave empty for the default "{number}-{slug}". # Supported tokens: {author}, {app}, {number}, {slug}; {slug} must not appear # before {number}, and the final path segment must start with {number}-. +# {number} receives the selected custom identifier, sequential number, or timestamp. # Example for monorepos: "{author}/{app}/{number}-{slug}" branch_template: "" @@ -84,6 +85,21 @@ auto_commit: For simple namespace-only customization, `branch_prefix` is also accepted as a shorthand and expands to `/{number}-{slug}`. +### Custom feature identifiers + +Supply a per-feature identifier through `FEATURE_ID` or the script option +`--feature-id` (`-FeatureId` in PowerShell). The value replaces the numeric or +timestamp prefix and is normalized to lowercase: + +```bash +FEATURE_ID=ENHANCEMENT-XYZ /speckit.specify Add user authentication +``` + +This produces `enhancement-xyz-user-auth` for both the Git branch and the +default `specs/` directory. Identifiers must start and end with a letter or +digit and may contain letters, digits, dots, underscores, and hyphens. +`GIT_BRANCH_NAME` remains the higher-precedence exact branch override. + ## Installation ```bash diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md index 01f664f84b..a3f8203e37 100644 --- a/extensions/git/commands/speckit.git.feature.md +++ b/extensions/git/commands/speckit.git.feature.md @@ -1,5 +1,5 @@ --- -description: "Create a feature branch with sequential or timestamp numbering" +description: "Create a feature branch with a custom identifier, sequential number, or timestamp" --- # Create Feature Branch @@ -21,6 +21,8 @@ If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variabl - `--short-name`, `--number`, and `--timestamp` flags are ignored - `FEATURE_NUM` is extracted when the final path segment starts with a numeric or timestamp feature marker (for example `042-name`, `feat/042-name`, or `jdoe/app/042-name`), otherwise set to the full branch name +If the user explicitly provided `FEATURE_ID` (for example, `FEATURE_ID=ENHANCEMENT-XYZ`), pass it to the script with `--feature-id`/`-FeatureId`. The script normalizes it to lowercase and creates a branch such as `enhancement-xyz-user-auth`. `GIT_BRANCH_NAME` takes precedence when both are provided. + ## Prerequisites - Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` @@ -41,7 +43,7 @@ Check `.specify/extensions/git/git-config.yml` for an optional `branch_template` - `{author}`: sanitized Git config author (`user.name`, falling back to the email local part) - `{app}`: sanitized Spec Kit init directory name -- `{number}`: sequential number or timestamp +- `{number}`: selected feature prefix (custom identifier, sequential number, or timestamp) - `{slug}`: generated short branch slug For monorepos, a template such as `{author}/{app}/{number}-{slug}` creates names like `jdoe/web/008-guided-tour` while preserving per-project feature numbering. @@ -61,6 +63,8 @@ Run the appropriate script based on your platform: - **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature-branch.sh --json --timestamp --short-name "" ""` - **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature-branch.ps1 -Json -ShortName "" ""` - **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature-branch.ps1 -Json -Timestamp -ShortName "" ""` +- **Bash (custom identifier)**: `.specify/extensions/git/scripts/bash/create-new-feature-branch.sh --json --feature-id "" --short-name "" ""` +- **PowerShell (custom identifier)**: `.specify/extensions/git/scripts/powershell/create-new-feature-branch.ps1 -Json -FeatureId "" -ShortName "" ""` **IMPORTANT**: - Do NOT pass `--number` — the script determines the correct next number automatically @@ -78,5 +82,5 @@ If Git is not installed or the current directory is not a Git repository: ## Output The script outputs JSON with: -- `BRANCH_NAME`: The branch name (e.g., `003-user-auth`, `20260319-143022-user-auth`, or `jdoe/web/003-user-auth`) -- `FEATURE_NUM`: The numeric or timestamp prefix used +- `BRANCH_NAME`: The branch name (e.g., `003-user-auth`, `20260319-143022-user-auth`, `enhancement-xyz-user-auth`, or `jdoe/web/003-user-auth`) +- `FEATURE_NUM`: The selected feature prefix; this legacy key also contains a custom identifier when one is used diff --git a/extensions/git/commands/speckit.git.validate.md b/extensions/git/commands/speckit.git.validate.md index c7feeb2600..32e1a089f2 100644 --- a/extensions/git/commands/speckit.git.validate.md +++ b/extensions/git/commands/speckit.git.validate.md @@ -26,6 +26,7 @@ The branch name's final path segment must start with one of these feature marker 1. **Sequential**: `[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`, `jdoe/web/008-guided-tour`) 2. **Timestamp**: `[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`, `jdoe/web/20260319-143022-feature-name`) +3. **Custom identifier**: the lowercase-normalized `FEATURE_ID` followed by `-`, or a final path segment that exactly matches the active feature directory basename from `.specify/feature.json` (e.g., `enhancement-xyz-user-auth`) ## Execution @@ -34,12 +35,13 @@ If on a feature branch (matches either pattern): - Check if the corresponding spec directory exists under `specs/`: - For sequential branches, look for `specs/-*` where prefix matches the numeric portion, regardless of branch namespace prefixes - For timestamp branches, look for `specs/-*` where prefix matches the `YYYYMMDD-HHMMSS` portion, regardless of branch namespace prefixes + - For custom identifiers, prefer the exact active feature directory recorded in `.specify/feature.json`; when `FEATURE_ID` is available, look for `specs/-*` - If spec directory exists: `✓ Spec directory found: ` - If spec directory missing: `⚠ No spec directory found for prefix ` If NOT on a feature branch: - Output: `✗ Not on a feature branch. Current branch: ` -- Output: `Feature branches should be named like: 001-feature-name, 20260319-143022-feature-name, or /001-feature-name` +- Output: `Feature branches should be named like: 001-feature-name, 20260319-143022-feature-name, enhancement-xyz-feature-name, or /001-feature-name` ## Graceful Degradation diff --git a/extensions/git/config-template.yml b/extensions/git/config-template.yml index 2ea3471742..aa12b56add 100644 --- a/extensions/git/config-template.yml +++ b/extensions/git/config-template.yml @@ -6,6 +6,7 @@ branch_numbering: sequential # Optional branch name template. Leave empty for the default "{number}-{slug}". # Supported tokens: {author}, {app}, {number}, {slug} +# {number} receives the selected prefix: custom FEATURE_ID, sequential number, or timestamp. # {slug} must not appear before {number}; final path segment must start with {number}-. # Example for monorepos: "{author}/{app}/{number}-{slug}" branch_template: "" diff --git a/extensions/git/extension.yml b/extensions/git/extension.yml index c92322d8b1..01f9fc27a8 100644 --- a/extensions/git/extension.yml +++ b/extensions/git/extension.yml @@ -3,8 +3,8 @@ schema_version: "1.0" extension: id: git name: "Git Branching Workflow" - version: "1.0.0" - description: "Feature branch creation, numbering (sequential/timestamp), templating, validation, and Git remote detection" + version: "1.0.1" + description: "Feature branch creation with custom identifiers or sequential/timestamp prefixes, templating, validation, and Git remote detection" author: spec-kit-core repository: https://github.com/github/spec-kit license: MIT @@ -19,7 +19,7 @@ provides: commands: - name: speckit.git.feature file: commands/speckit.git.feature.md - description: "Create a feature branch with sequential or timestamp numbering and optional templates" + description: "Create a feature branch with a custom identifier, sequential number, or timestamp and optional templates" - name: speckit.git.validate file: commands/speckit.git.validate.md description: "Validate current branch follows feature branch naming conventions" diff --git a/extensions/git/git-config.yml b/extensions/git/git-config.yml index 2ea3471742..aa12b56add 100644 --- a/extensions/git/git-config.yml +++ b/extensions/git/git-config.yml @@ -6,6 +6,7 @@ branch_numbering: sequential # Optional branch name template. Leave empty for the default "{number}-{slug}". # Supported tokens: {author}, {app}, {number}, {slug} +# {number} receives the selected prefix: custom FEATURE_ID, sequential number, or timestamp. # {slug} must not appear before {number}; final path segment must start with {number}-. # Example for monorepos: "{author}/{app}/{number}-{slug}" branch_template: "" diff --git a/extensions/git/scripts/bash/create-new-feature-branch.sh b/extensions/git/scripts/bash/create-new-feature-branch.sh index 5303bb97c9..3fabac4652 100755 --- a/extensions/git/scripts/bash/create-new-feature-branch.sh +++ b/extensions/git/scripts/bash/create-new-feature-branch.sh @@ -13,6 +13,7 @@ ALLOW_EXISTING=false SHORT_NAME="" BRANCH_NUMBER="" USE_TIMESTAMP=false +FEATURE_ID_ARG="" ARGS=() i=1 while [ $i -le $# ]; do @@ -60,8 +61,21 @@ while [ $i -le $# ]; do --timestamp) USE_TIMESTAMP=true ;; + --feature-id) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --feature-id requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --feature-id requires a value' >&2 + exit 1 + fi + FEATURE_ID_ARG="$next_arg" + ;; --help|-h) - echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] [--feature-id ] " echo "" echo "Options:" echo " --json Output in JSON format" @@ -70,10 +84,12 @@ while [ $i -le $# ]; do echo " --short-name Provide a custom short name (2-4 words) for the branch" echo " --number N Specify branch number manually (overrides auto-detection)" echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + echo " --feature-id Use a custom prefix such as ENHANCEMENT-XYZ" 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 " FEATURE_ID Custom prefix used when --feature-id is not provided" echo "" echo "Configuration:" echo " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}" @@ -83,6 +99,7 @@ while [ $i -le $# ]; do 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 " $0 --feature-id ENHANCEMENT-XYZ --short-name 'user-auth' 'Add user authentication'" echo " GIT_BRANCH_NAME=my-branch $0 'feature description'" exit 0 ;; @@ -95,7 +112,7 @@ done FEATURE_DESCRIPTION="${ARGS[*]}" if [ -z "$FEATURE_DESCRIPTION" ]; then - echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] [--feature-id ] " >&2 exit 1 fi @@ -446,6 +463,8 @@ AUTHOR_TOKEN=$(get_author_token) APP_TOKEN=$(get_app_token) BRANCH_TEMPLATE=$(resolve_branch_template) validate_branch_template "$BRANCH_TEMPLATE" +CUSTOM_FEATURE_ID="${FEATURE_ID_ARG:-${FEATURE_ID:-}}" +CUSTOM_FEATURE_ID=$(printf '%s' "$CUSTOM_FEATURE_ID" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') # Function to generate branch name with stop word filtering generate_branch_name() { @@ -498,6 +517,12 @@ if [ -n "${GIT_BRANCH_NAME:-}" ]; then FEATURE_NUM=$(extract_feature_num_from_branch "$BRANCH_NAME") BRANCH_SUFFIX="$BRANCH_NAME" else + if [ -n "$CUSTOM_FEATURE_ID" ] && [[ ! "$CUSTOM_FEATURE_ID" =~ ^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$ ]]; then + echo "Error: feature identifier must start and end with a letter or digit and contain only letters, digits, dots, underscores, or hyphens" >&2 + exit 1 + fi + CUSTOM_FEATURE_ID=$(printf '%s' "$CUSTOM_FEATURE_ID" | LC_ALL=C tr '[:upper:]' '[:lower:]') + # Generate branch name if [ -n "$SHORT_NAME" ]; then BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") @@ -506,13 +531,28 @@ else fi # Warn if --number and --timestamp are both specified - if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then + if [ -n "$CUSTOM_FEATURE_ID" ] && { [ "$USE_TIMESTAMP" = true ] || [ -n "$BRANCH_NUMBER" ]; }; then + >&2 echo "[specify] Warning: --number and --timestamp are ignored when a custom feature identifier is used" + BRANCH_NUMBER="" + elif [ "$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 + if [ -n "$CUSTOM_FEATURE_ID" ]; then + FEATURE_NUM="$CUSTOM_FEATURE_ID" + BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX") + FEATURE_SEGMENT="${BRANCH_NAME##*/}" + if [ "$ALLOW_EXISTING" != true ] || [ ! -d "$SPECS_DIR/$FEATURE_SEGMENT" ]; then + for spec_path in "$SPECS_DIR/${FEATURE_NUM}-"*; do + if [ -d "$spec_path" ]; then + echo "Error: feature identifier '$FEATURE_NUM' conflicts with an existing spec directory" >&2 + exit 1 + fi + done + fi + elif [ "$USE_TIMESTAMP" = true ]; then FEATURE_NUM=$(date +%Y%m%d-%H%M%S) BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX") else diff --git a/extensions/git/scripts/bash/git-common.sh b/extensions/git/scripts/bash/git-common.sh index 60bb79c867..74edeaae85 100755 --- a/extensions/git/scripts/bash/git-common.sh +++ b/extensions/git/scripts/bash/git-common.sh @@ -29,6 +29,12 @@ spec_kit_effective_branch_name() { check_feature_branch() { local raw="$1" local has_git_repo="$2" + local feature_id="${3:-${FEATURE_ID:-}}" + feature_id=$(printf '%s' "$feature_id" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') + if [ -n "$feature_id" ] \ + && [[ "$feature_id" =~ ^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$ ]]; then + feature_id=$(printf '%s' "$feature_id" | LC_ALL=C tr '[:upper:]' '[:lower:]') + fi # For non-git repos, we can't enforce branch naming but still provide output if [[ "$has_git_repo" != "true" ]]; then @@ -46,9 +52,17 @@ check_feature_branch() { if [[ "$feature_segment" =~ ^[0-9]{3,}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then is_sequential=true fi - if [[ "$is_sequential" != "true" ]] && [[ ! "$feature_segment" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + local is_custom=false + if [ -n "$feature_id" ] \ + && [[ "$feature_id" =~ ^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$ ]] \ + && [[ "$feature_segment" == "$feature_id-"* ]]; then + is_custom=true + fi + if [[ "$is_sequential" != "true" ]] \ + && [[ ! "$feature_segment" =~ ^[0-9]{8}-[0-9]{6}- ]] \ + && [[ "$is_custom" != "true" ]]; then echo "ERROR: Not on a feature branch. Current branch: $raw" >&2 - echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or /001-feature-name" >&2 + echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, enhancement-xyz-feature-name, or /001-feature-name" >&2 return 1 fi diff --git a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 index 4d06a113a4..c238896198 100644 --- a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 @@ -13,6 +13,7 @@ param( [Parameter()] [long]$Number = 0, [switch]$Timestamp, + [string]$FeatureId, [switch]$Help, [Parameter(Position = 0, ValueFromRemainingArguments = $true)] [string[]]$FeatureDescription @@ -20,7 +21,7 @@ param( $ErrorActionPreference = 'Stop' if ($Help) { - Write-Host "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Host "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] [-FeatureId ] " Write-Host "" Write-Host "Options:" Write-Host " -Json Output in JSON format" @@ -29,10 +30,12 @@ if ($Help) { 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)" Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + Write-Host " -FeatureId Use a custom prefix such as ENHANCEMENT-XYZ" 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 " FEATURE_ID Custom prefix used when -FeatureId is not provided" Write-Host "" Write-Host "Configuration:" Write-Host " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}" @@ -52,7 +55,7 @@ if ($Number -lt 0) { } if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { - Write-Error "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Error "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] [-FeatureId ] " exit 1 } @@ -400,6 +403,8 @@ $authorToken = Get-GitAuthorToken $appToken = Get-AppToken $branchTemplate = Resolve-BranchTemplate Assert-BranchTemplateValid -Template $branchTemplate +$customFeatureId = if ($PSBoundParameters.ContainsKey('FeatureId')) { $FeatureId } else { $env:FEATURE_ID } +$customFeatureId = ([string]$customFeatureId).Trim() function Get-BranchName { param([string]$Description) @@ -452,6 +457,12 @@ if ($env:GIT_BRANCH_NAME) { } $featureNum = Get-FeatureNumberFromBranchName -BranchName $branchName } else { + if ($customFeatureId -and $customFeatureId -notmatch '^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$') { + Write-Error "Error: feature identifier must start and end with a letter or digit and contain only letters, digits, dots, underscores, or hyphens" + exit 1 + } + $customFeatureId = $customFeatureId.ToLowerInvariant() + if ($ShortName) { $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName } else { @@ -461,12 +472,31 @@ if ($env:GIT_BRANCH_NAME) { # Warn if -Number and -Timestamp are both specified. Use ContainsKey (not # `-ne 0`) so an explicit `-Number 0` is also detected, matching the bash twin's # `[ -n "$BRANCH_NUMBER" ]` check. - if ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) { + if ($customFeatureId -and ($Timestamp -or $PSBoundParameters.ContainsKey('Number'))) { + [Console]::Error.WriteLine("[specify] Warning: --number and --timestamp are ignored when a custom feature identifier is used") + $Number = 0 + } elseif ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) { Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" $Number = 0 } - if ($Timestamp) { + if ($customFeatureId) { + $featureNum = $customFeatureId + $branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix + $featureSegment = ($branchName -split '/')[-1] + $conflictingSpec = $null + $requestedSpecDir = Join-Path $specsDir $featureSegment + if ((-not $AllowExistingBranch -or -not (Test-Path -LiteralPath $requestedSpecDir -PathType Container)) -and + (Test-Path -LiteralPath $specsDir -PathType Container)) { + $conflictingSpec = Get-ChildItem -LiteralPath $specsDir -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name.StartsWith("$featureNum-", [System.StringComparison]::Ordinal) } | + Select-Object -First 1 + } + if ($null -ne $conflictingSpec) { + Write-Error "Error: feature identifier '$featureNum' conflicts with an existing spec directory" + exit 1 + } + } elseif ($Timestamp) { $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' $branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix } else { diff --git a/extensions/git/scripts/powershell/git-common.ps1 b/extensions/git/scripts/powershell/git-common.ps1 index a7ea724a31..48026a7c3b 100644 --- a/extensions/git/scripts/powershell/git-common.ps1 +++ b/extensions/git/scripts/powershell/git-common.ps1 @@ -26,7 +26,8 @@ function Get-SpecKitEffectiveBranchName { function Test-FeatureBranch { param( [string]$Branch, - [bool]$HasGit = $true + [bool]$HasGit = $true, + [string]$FeatureId = $env:FEATURE_ID ) # For non-git repos, we can't enforce branch naming but still provide output @@ -38,14 +39,21 @@ function Test-FeatureBranch { $raw = $Branch $Branch = Get-SpecKitEffectiveBranchName $raw $featureSegment = ($Branch -split '/')[-1] + $FeatureId = $FeatureId.Trim() + if ($FeatureId -match '^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$') { + $FeatureId = $FeatureId.ToLowerInvariant() + } # Accept sequential prefix (3+ digits), at the start or after namespace # segments, but exclude malformed timestamps. $hasMalformedTimestamp = ($featureSegment -match '^[0-9]{7}-[0-9]{6}-') -or ($featureSegment -match '^(?:\d{7}|\d{8})-\d{6}$') $isSequential = ($featureSegment -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) - if (-not $isSequential -and $featureSegment -notmatch '^\d{8}-\d{6}-') { + $isCustom = $FeatureId -and + $FeatureId -match '^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$' -and + $featureSegment.StartsWith("$FeatureId-", [System.StringComparison]::Ordinal) + if (-not $isSequential -and $featureSegment -notmatch '^\d{8}-\d{6}-' -and -not $isCustom) { [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw") - [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or /001-feature-name") + [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, enhancement-xyz-feature-name, or /001-feature-name") return $false } return $true diff --git a/extensions/git/scripts/python/create_new_feature_branch.py b/extensions/git/scripts/python/create_new_feature_branch.py index 202b1f796e..246325e78b 100644 --- a/extensions/git/scripts/python/create_new_feature_branch.py +++ b/extensions/git/scripts/python/create_new_feature_branch.py @@ -28,7 +28,7 @@ USAGE = ( "Usage: create_new_feature_branch.py [--json] [--dry-run] " "[--allow-existing-branch] [--short-name ] [--number N] " - "[--timestamp] " + "[--timestamp] [--feature-id ] " ) HELP_TEXT = f"""{USAGE} @@ -40,10 +40,12 @@ --short-name Provide a custom short name (2-4 words) for the branch --number N Specify branch number manually (overrides auto-detection) --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering + --feature-id Use a custom prefix such as ENHANCEMENT-XYZ --help, -h Show this help message Environment variables: GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation + FEATURE_ID Custom prefix used when --feature-id is not provided Configuration: branch_template Optional git-config.yml template with {{author}}, {{app}}, {{number}}, {{slug}} @@ -53,6 +55,7 @@ create_new_feature_branch.py 'Add user authentication system' --short-name 'user-auth' create_new_feature_branch.py 'Implement OAuth2 integration for API' --number 5 create_new_feature_branch.py --timestamp --short-name 'user-auth' 'Add user authentication' + create_new_feature_branch.py --feature-id ENHANCEMENT-XYZ --short-name 'user-auth' 'Add user authentication' GIT_BRANCH_NAME=my-branch create_new_feature_branch.py 'feature description' """ @@ -84,6 +87,7 @@ class Args: short_name: str = "" branch_number: str = "" use_timestamp: bool = False + feature_id: str = "" description_parts: list[str] = field(default_factory=list) @@ -113,6 +117,12 @@ def parse_args(argv: list[str]) -> Args: if not re.fullmatch(r"[0-9]+", args.branch_number): _err("Error: --number must be a non-negative integer") raise SystemExit(1) + elif arg == "--feature-id": + if i + 1 >= len(argv) or argv[i + 1].startswith("--"): + _err("Error: --feature-id requires a value") + raise SystemExit(1) + i += 1 + args.feature_id = argv[i] elif arg == "--timestamp": args.use_timestamp = True elif arg in ("--help", "-h"): @@ -286,6 +296,13 @@ def clean_branch_name(name: str) -> str: return name.strip("-") +def validate_feature_id(feature_id: str) -> bool: + """Return whether a custom identifier is a safe single path segment.""" + return bool( + re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?", feature_id) + ) + + def generate_branch_name(description: str) -> str: """Generate a branch suffix from the description with stop word filtering.""" clean_name = re.sub(r"[^a-z0-9]", " ", description.lower()) @@ -496,16 +513,54 @@ def build_branch_name(feature_num: str, branch_suffix: str) -> str: feature_num = extract_feature_num_from_branch(branch_name) branch_suffix = branch_name else: + custom_feature_id = ( + args.feature_id or os.environ.get("FEATURE_ID", "") + ).strip() + if custom_feature_id and not validate_feature_id(custom_feature_id): + _err( + "Error: feature identifier must start and end with a letter or digit " + "and contain only letters, digits, dots, underscores, or hyphens" + ) + return 1 + custom_feature_id = custom_feature_id.lower() + if args.short_name: branch_suffix = clean_branch_name(args.short_name) else: branch_suffix = generate_branch_name(feature_description) - if args.use_timestamp and branch_number: + if custom_feature_id and (args.use_timestamp or branch_number): + _err( + "[specify] Warning: --number and --timestamp are ignored when a " + "custom feature identifier is used" + ) + branch_number = "" + elif args.use_timestamp and branch_number: _err("[specify] Warning: --number is ignored when --timestamp is used") branch_number = "" - if args.use_timestamp: + if custom_feature_id: + feature_num = custom_feature_id + requested_branch_name = build_branch_name(feature_num, branch_suffix) + requested_feature_segment = requested_branch_name.rsplit("/", 1)[-1] + requested_dir = specs_dir / requested_feature_segment + prefix_in_use = ( + specs_dir.is_dir() + and any( + entry.is_dir() and entry.name.startswith(f"{feature_num}-") + for entry in specs_dir.iterdir() + ) + ) + if prefix_in_use and not ( + args.allow_existing and requested_dir.is_dir() + ): + _err( + f"Error: feature identifier '{feature_num}' conflicts with an " + "existing spec directory" + ) + return 1 + branch_name = requested_branch_name + elif args.use_timestamp: feature_num = datetime.now().strftime("%Y%m%d-%H%M%S") branch_name = build_branch_name(feature_num, branch_suffix) else: diff --git a/extensions/git/scripts/python/git_common.py b/extensions/git/scripts/python/git_common.py index 9b23fbaa06..1df00948c1 100644 --- a/extensions/git/scripts/python/git_common.py +++ b/extensions/git/scripts/python/git_common.py @@ -7,6 +7,7 @@ from __future__ import annotations +import os import re import shutil import subprocess @@ -42,7 +43,9 @@ def effective_branch_name(raw: str) -> str: return raw -def check_feature_branch(raw: str, has_git_repo: bool) -> bool: +def check_feature_branch( + raw: str, has_git_repo: bool, feature_id: str | None = None +) -> bool: """Validate that a branch name matches the expected feature branch pattern. Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) @@ -67,12 +70,23 @@ def check_feature_branch(raw: str, has_git_repo: bool) -> bool: and not re.fullmatch(r"[0-9]{7,8}-[0-9]{6}", feature_segment) ) is_timestamp = bool(re.match(r"^[0-9]{8}-[0-9]{6}-", feature_segment)) + custom_id = (feature_id if feature_id is not None else os.environ.get("FEATURE_ID", "")).strip() + if re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?", custom_id): + custom_id = custom_id.lower() + is_custom = bool( + custom_id + and re.fullmatch( + r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?", custom_id + ) + and feature_segment.startswith(f"{custom_id}-") + ) - if not is_sequential and not is_timestamp: + if not is_sequential and not is_timestamp and not is_custom: print(f"ERROR: Not on a feature branch. Current branch: {raw}", file=sys.stderr) print( "Feature branches should be named like: 001-feature-name, " - "1234-feature-name, 20260319-143022-feature-name, or " + "1234-feature-name, 20260319-143022-feature-name, " + "enhancement-xyz-feature-name, or " "/001-feature-name", file=sys.stderr, ) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 06681d9b6e..77bdf18c2a 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -8,6 +8,7 @@ ALLOW_EXISTING=false SHORT_NAME="" BRANCH_NUMBER="" USE_TIMESTAMP=false +FEATURE_ID_ARG="" NUMBER_EXPLICIT=false ARGS=() i=1 @@ -56,8 +57,21 @@ while [ $i -le $# ]; do --timestamp) USE_TIMESTAMP=true ;; + --feature-id) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --feature-id requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --feature-id requires a value' >&2 + exit 1 + fi + FEATURE_ID_ARG="$next_arg" + ;; --help|-h) - echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] [--feature-id ] " echo "" echo "Options:" echo " --json Output in JSON format" @@ -66,12 +80,17 @@ while [ $i -le $# ]; do echo " --short-name Provide a custom short name (2-4 words) for the feature" echo " --number N Prefer a feature number (auto-corrected if its specs prefix exists)" echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + echo " --feature-id Use a custom prefix such as ENHANCEMENT-XYZ" echo " --help, -h Show this help message" echo "" + echo "Environment variables:" + echo " FEATURE_ID Custom prefix used when --feature-id is not provided" + 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 " $0 --feature-id ENHANCEMENT-XYZ --short-name 'user-auth' 'Add user authentication'" exit 0 ;; *) @@ -83,7 +102,7 @@ done FEATURE_DESCRIPTION="${ARGS[*]}" if [ -z "$FEATURE_DESCRIPTION" ]; then - echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] [--feature-id ] " >&2 exit 1 fi @@ -168,6 +187,10 @@ fit_branch_name() { if [ ${#branch_name} -gt $MAX_BRANCH_LENGTH ]; then local prefix_length=$(( ${#feature_num} + 1 )) local max_suffix_length=$((MAX_BRANCH_LENGTH - prefix_length)) + if [ "$max_suffix_length" -le 0 ]; then + echo "Error: feature prefix is too long for a branch name" >&2 + return 1 + fi local truncated_suffix truncated_suffix=$(printf '%s' "$branch_suffix" | cut -c "1-$max_suffix_length" | sed 's/-$//') branch_name="${feature_num}-${truncated_suffix}" @@ -266,13 +289,33 @@ else fi # Warn if --number and --timestamp are both specified -if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then +CUSTOM_FEATURE_ID="${FEATURE_ID_ARG:-${FEATURE_ID:-}}" +CUSTOM_FEATURE_ID=$(printf '%s' "$CUSTOM_FEATURE_ID" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') +if [ -n "$CUSTOM_FEATURE_ID" ] && [[ ! "$CUSTOM_FEATURE_ID" =~ ^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$ ]]; then + echo "Error: feature identifier must start and end with a letter or digit and contain only letters, digits, dots, underscores, or hyphens" >&2 + exit 1 +fi +CUSTOM_FEATURE_ID=$(printf '%s' "$CUSTOM_FEATURE_ID" | LC_ALL=C tr '[:upper:]' '[:lower:]') + +if [ -n "$CUSTOM_FEATURE_ID" ] && { [ "$USE_TIMESTAMP" = true ] || [ -n "$BRANCH_NUMBER" ]; }; then + >&2 echo "[specify] Warning: --number and --timestamp are ignored when a custom feature identifier is used" + BRANCH_NUMBER="" +elif [ "$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 +if [ -n "$CUSTOM_FEATURE_ID" ]; then + FEATURE_NUM="$CUSTOM_FEATURE_ID" + REQUESTED_BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX") + REQUESTED_DIR="$SPECS_DIR/$REQUESTED_BRANCH_NAME" + if { [ "$ALLOW_EXISTING" != true ] || [ ! -d "$REQUESTED_DIR" ]; } \ + && spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM"; then + echo "Error: feature identifier '$FEATURE_NUM' conflicts with an existing spec directory" >&2 + exit 1 + fi +elif [ "$USE_TIMESTAMP" = true ]; then FEATURE_NUM=$(date +%Y%m%d-%H%M%S) BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" else diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 9ce2c678a4..93016882cb 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -9,6 +9,7 @@ param( [Parameter()] [string]$Number = '', [switch]$Timestamp, + [string]$FeatureId, [switch]$Help, [Parameter(Position = 0, ValueFromRemainingArguments = $true)] [string[]]$FeatureDescription @@ -18,7 +19,7 @@ $maxBranchLength = 244 # Show help if requested if ($Help) { - Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] [-FeatureId ] " Write-Host "" Write-Host "Options:" Write-Host " -Json Output in JSON format" @@ -27,18 +28,23 @@ if ($Help) { Write-Host " -ShortName Provide a custom short name (2-4 words) for the feature" Write-Host " -Number N Prefer a feature number (auto-corrected if its specs prefix exists)" Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + Write-Host " -FeatureId Use a custom prefix such as ENHANCEMENT-XYZ" Write-Host " -Help Show this help message" Write-Host "" + Write-Host "Environment variables:" + Write-Host " FEATURE_ID Custom prefix used when -FeatureId is not provided" + Write-Host "" Write-Host "Examples:" Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'" Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'" Write-Host " ./create-new-feature.ps1 -Timestamp -ShortName 'user-auth' 'Add user authentication'" + Write-Host " ./create-new-feature.ps1 -FeatureId ENHANCEMENT-XYZ -ShortName 'user-auth' 'Add user authentication'" exit 0 } # Check if feature description provided if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { - Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] [-FeatureId ] " exit 1 } @@ -99,6 +105,9 @@ function Get-FittedBranchName { if ($fittedName.Length -gt $maxBranchLength) { $prefixLength = $FeatureNum.Length + 1 $maxSuffixLength = $maxBranchLength - $prefixLength + if ($maxSuffixLength -le 0) { + throw "feature prefix is too long for a branch name" + } $truncatedSuffix = $BranchSuffix.Substring(0, [Math]::Min($BranchSuffix.Length, $maxSuffixLength)) $truncatedSuffix = $truncatedSuffix -replace '-$', '' $fittedName = "$FeatureNum-$truncatedSuffix" @@ -187,15 +196,35 @@ if ($ShortName) { # Treat an explicit empty string as omitted, matching the bash and Python twins. $hasNumber = $PSBoundParameters.ContainsKey('Number') -and $Number -ne '' +$customFeatureId = if ($PSBoundParameters.ContainsKey('FeatureId')) { $FeatureId } else { $env:FEATURE_ID } +$customFeatureId = ([string]$customFeatureId).Trim() +if ($customFeatureId -and $customFeatureId -notmatch '^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$') { + Write-Error "Error: feature identifier must start and end with a letter or digit and contain only letters, digits, dots, underscores, or hyphens" + exit 1 +} +$customFeatureId = $customFeatureId.ToLowerInvariant() # Warn if -Number and -Timestamp are both specified. -if ($Timestamp -and $hasNumber) { +if ($customFeatureId -and ($Timestamp -or $hasNumber)) { + [Console]::Error.WriteLine("[specify] Warning: --number and --timestamp are ignored when a custom feature identifier is used") + $Number = '' + $hasNumber = $false +} elseif ($Timestamp -and $hasNumber) { [Console]::Error.WriteLine("[specify] Warning: -Number is ignored when -Timestamp is used") $Number = '' } # Determine branch prefix -if ($Timestamp) { +if ($customFeatureId) { + $featureNum = $customFeatureId + $requestedBranchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix + $requestedDir = Join-Path $specsDir $requestedBranchName + if ((-not $AllowExistingBranch -or -not (Test-Path -LiteralPath $requestedDir -PathType Container)) -and + (Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum)) { + Write-Error "Error: feature identifier '$featureNum' conflicts with an existing spec directory" + exit 1 + } +} elseif ($Timestamp) { $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' $branchName = "$featureNum-$branchSuffix" } else { diff --git a/scripts/python/create_new_feature.py b/scripts/python/create_new_feature.py index f36064afbb..0e5696a70c 100644 --- a/scripts/python/create_new_feature.py +++ b/scripts/python/create_new_feature.py @@ -5,6 +5,7 @@ import datetime import json +import os import re import shlex import sys @@ -73,7 +74,8 @@ def _persistence_assignments( def _usage(argv0: str) -> str: return ( f"Usage: {argv0} [--json] [--dry-run] [--allow-existing-branch] " - "[--short-name ] [--number N] [--timestamp] " + "[--short-name ] [--number N] [--timestamp] " + "[--feature-id ] " ) @@ -87,12 +89,17 @@ def _help_text(argv0: str) -> str: --short-name Provide a custom short name (2-4 words) for the feature --number N Prefer a feature number (auto-corrected if its specs prefix exists) --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering + --feature-id Use a custom prefix such as ENHANCEMENT-XYZ --help, -h Show this help message +Environment variables: + FEATURE_ID Custom prefix used when --feature-id is not provided + Examples: {argv0} 'Add user authentication system' --short-name 'user-auth' {argv0} 'Implement OAuth2 integration for API' --number 5 {argv0} --timestamp --short-name 'user-auth' 'Add user authentication' + {argv0} --feature-id ENHANCEMENT-XYZ --short-name 'user-auth' 'Add user authentication' """ @@ -104,6 +111,7 @@ class Args: short_name: str = "" branch_number: str = "" use_timestamp: bool = False + feature_id: str = "" description: str = "" @@ -114,6 +122,7 @@ def _parse_args(argv: list[str], argv0: str) -> Args: short_name = "" branch_number = "" use_timestamp = False + feature_id = "" rest: list[str] = [] i = 0 @@ -125,13 +134,15 @@ def _parse_args(argv: list[str], argv0: str) -> Args: dry_run = True elif arg == "--allow-existing-branch": allow_existing = True - elif arg in {"--short-name", "--number"}: + elif arg in {"--short-name", "--number", "--feature-id"}: if i + 1 >= len(argv) or argv[i + 1].startswith("--"): print(f"Error: {arg} requires a value", file=sys.stderr) raise SystemExit(1) i += 1 if arg == "--short-name": short_name = argv[i] + elif arg == "--feature-id": + feature_id = argv[i] else: branch_number = argv[i] elif arg == "--timestamp": @@ -161,6 +172,7 @@ def _parse_args(argv: list[str], argv0: str) -> Args: short_name=short_name, branch_number=branch_number, use_timestamp=use_timestamp, + feature_id=feature_id, description=description, ) @@ -224,6 +236,13 @@ def _fit_branch_name(feature_num: str, branch_suffix: str) -> str: return f"{feature_num}-{truncated_suffix}" +def _validate_feature_id(feature_id: str) -> bool: + """Return whether a custom identifier is a safe single path segment.""" + return bool( + re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?", feature_id) + ) + + def _spec_prefix_exists(specs_dir: Path, feature_num: str) -> bool: """Return whether a spec directory owns the given numeric prefix.""" try: @@ -265,14 +284,47 @@ def main(argv: list[str] | None = None) -> int: branch_suffix = _generate_branch_name(args.description) branch_number = args.branch_number - if args.use_timestamp and branch_number: + feature_id = (args.feature_id or os.environ.get("FEATURE_ID", "")).strip() + if feature_id and not _validate_feature_id(feature_id): + print( + "Error: feature identifier must start and end with a letter or digit " + "and contain only letters, digits, dots, underscores, or hyphens", + file=sys.stderr, + ) + return 1 + feature_id = feature_id.lower() + + if feature_id and (args.use_timestamp or branch_number): + print( + "[specify] Warning: --number and --timestamp are ignored when a " + "custom feature identifier is used", + file=sys.stderr, + ) + branch_number = "" + elif args.use_timestamp and branch_number: print( "[specify] Warning: --number is ignored when --timestamp is used", file=sys.stderr, ) branch_number = "" - if args.use_timestamp: + if feature_id: + feature_num = feature_id + requested_branch_name = _fit_branch_name(feature_num, branch_suffix) + requested_dir = specs_dir / requested_branch_name + if _has_spec_prefix_conflict( + specs_dir, + feature_num, + requested_dir, + allow_existing=args.allow_existing, + ): + print( + f"Error: feature identifier '{feature_num}' conflicts with an " + "existing spec directory", + file=sys.stderr, + ) + return 1 + elif args.use_timestamp: feature_num = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") else: if branch_number: @@ -341,7 +393,7 @@ def main(argv: list[str] | None = None) -> int: max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1) if max_suffix_length <= 0: - print("Error: feature number is too long for a branch name", file=sys.stderr) + print("Error: feature prefix is too long for a branch name", file=sys.stderr) return 1 original_branch_name = f"{feature_num}-{branch_suffix}" diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 52b2b24565..345fb9f1c9 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -51,6 +51,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Outline. ``` After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. +- When invoking a `before_specify` hook, forward any explicit `GIT_BRANCH_NAME` and `FEATURE_ID` values from the user's input so branch creation uses the requested naming override. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Outline @@ -77,6 +78,8 @@ Given that feature description, do this: 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). + If the user explicitly provided `FEATURE_ID` (for example, `FEATURE_ID=ENHANCEMENT-XYZ`), pass it through to the hook. The hook must normalize it to lowercase and use it as the feature prefix instead of a sequential number or timestamp. + 3. **Create the spec feature directory**: Specs live under the default `specs/` directory unless the user explicitly provides `SPECIFY_FEATURE_DIRECTORY`. @@ -84,10 +87,13 @@ Given that feature description, do this: **Resolution order for `SPECIFY_FEATURE_DIRECTORY`**: 1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is 2. Otherwise, auto-generate it under `specs/`: - - Check `.specify/init-options.json` for `feature_numbering` (preferred) or `branch_numbering` (deprecated, migration only — will be removed in a future release) + - If the user explicitly provided `FEATURE_ID`, validate that it starts and ends with a letter or digit and contains only letters, digits, dots, underscores, or hyphens. Normalize it to lowercase and use it as the prefix. + - Otherwise, check `.specify/init-options.json` for `feature_numbering` (preferred) or `branch_numbering` (deprecated, migration only — will be removed in a future release) - If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp) - If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`) - - Construct the directory name: `-` (e.g., `003-user-auth` or `20260319-143022-user-auth`) + - Construct the directory name: `-` (e.g., `003-user-auth`, `20260319-143022-user-auth`, or `enhancement-xyz-user-auth`) + - When `FEATURE_ID` was provided and the branch hook returned `BRANCH_NAME`, use the branch name's final path segment as the directory name after verifying it starts with `-`. This keeps custom-ID branches and folders aligned even when a branch namespace template is active. + - If a different directory already starts with `-`, stop with an error; custom identifiers are never silently changed - Set `SPECIFY_FEATURE_DIRECTORY` to `specs/` - If `branch_numbering` was used (and `feature_numbering` was absent), emit a one-line warning: "⚠️ `branch_numbering` in init-options.json is deprecated. Rename to `feature_numbering`." diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index f6be51caf6..1760e496d1 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -145,7 +145,7 @@ def test_manifest_validates(self): m = ExtensionManifest(EXT_DIR / "extension.yml") assert m.id == "git" - assert m.version == "1.0.0" + assert m.version == "1.0.1" def test_manifest_commands(self): """Manifest declares expected commands.""" diff --git a/tests/extensions/git/test_git_extension_python_parity.py b/tests/extensions/git/test_git_extension_python_parity.py index 8cae45e783..a2b46119a1 100644 --- a/tests/extensions/git/test_git_extension_python_parity.py +++ b/tests/extensions/git/test_git_extension_python_parity.py @@ -310,6 +310,114 @@ def test_timestamp_mode_format(self, tmp_path: Path): assert re.fullmatch(r"[0-9]{8}-[0-9]{6}", data["FEATURE_NUM"]) assert data["BRANCH_NAME"] == f"{data['FEATURE_NUM']}-user-auth" + def test_custom_feature_id_normalizes_to_lowercase(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + args = ( + "--json", + "--dry-run", + "--feature-id", + "ENHANCEMENT-XYZ", + "--short-name", + "user-auth", + "desc", + ) + b = _run_bash("create-new-feature-branch.sh", bash_proj, *args) + p = _run_py("create-new-feature-branch", py_proj, *args) + _assert_parity(b, p) + assert json.loads(p.stdout) == { + "BRANCH_NAME": "enhancement-xyz-user-auth", + "FEATURE_NUM": "enhancement-xyz", + "DRY_RUN": True, + } + + def test_custom_feature_id_environment(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + env = {"FEATURE_ID": "WORK_ITEM.42"} + args = ("--json", "--dry-run", "--short-name", "sync", "desc") + b = _run_bash( + "create-new-feature-branch.sh", bash_proj, *args, env_extra=env + ) + p = _run_py( + "create-new-feature-branch", py_proj, *args, env_extra=env + ) + _assert_parity(b, p) + assert json.loads(p.stdout)["BRANCH_NAME"] == "work_item.42-sync" + + def test_custom_feature_id_creates_matching_branch(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + args = ( + "--json", + "--feature-id", + "ENHANCEMENT-XYZ", + "--short-name", + "user-auth", + "desc", + ) + b = _run_bash("create-new-feature-branch.sh", bash_proj, *args) + p = _run_py("create-new-feature-branch", py_proj, *args) + _assert_parity(b, p) + for project in (bash_proj, py_proj): + branch = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=project, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + assert branch == "enhancement-xyz-user-auth" + + @pytest.mark.parametrize("feature_id", ["../BAD", "-BAD", "BAD/", "BAD value"]) + def test_custom_feature_id_rejects_unsafe_values( + self, tmp_path: Path, feature_id: str + ): + bash_proj, py_proj = _twin_projects(tmp_path) + args = ("--json", "--dry-run", "--feature-id", feature_id, "desc") + b = _run_bash("create-new-feature-branch.sh", bash_proj, *args) + p = _run_py("create-new-feature-branch", py_proj, *args) + _assert_parity(b, p) + assert p.returncode == 1 + assert "feature identifier must start and end" in p.stderr + + def test_custom_feature_id_uses_branch_template(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + for proj in (bash_proj, py_proj): + _write_config(proj, 'branch_template: "{author}/{app}/{number}-{slug}"\n') + args = ( + "--json", + "--dry-run", + "--feature-id", + "ENHANCEMENT-XYZ", + "--short-name", + "user-auth", + "desc", + ) + b = _run_bash("create-new-feature-branch.sh", bash_proj, *args) + p = _run_py("create-new-feature-branch", py_proj, *args) + _assert_parity(b, p) + assert ( + json.loads(p.stdout)["BRANCH_NAME"] + == "test-user/proj/enhancement-xyz-user-auth" + ) + + def test_custom_feature_id_conflict_fails(self, tmp_path: Path): + bash_proj, py_proj = _twin_projects(tmp_path) + for proj in (bash_proj, py_proj): + (proj / "specs" / "enhancement-xyz-existing").mkdir(parents=True) + args = ( + "--json", + "--dry-run", + "--feature-id", + "ENHANCEMENT-XYZ", + "--short-name", + "new", + "desc", + ) + b = _run_bash("create-new-feature-branch.sh", bash_proj, *args) + p = _run_py("create-new-feature-branch", py_proj, *args) + _assert_parity(b, p) + assert p.returncode == 1 + assert "conflicts with an existing spec directory" in p.stderr + def test_timestamp_with_number_warns(self, tmp_path: Path): bash_proj, py_proj = _twin_projects(tmp_path) b = _run_bash( @@ -795,6 +903,14 @@ def test_has_git(self, git_common, tmp_path: Path): def test_check_feature_branch(self, git_common, branch: str, expected: bool): assert git_common.check_feature_branch(branch, True) is expected + def test_check_feature_branch_with_custom_identifier(self, git_common): + assert git_common.check_feature_branch( + "enhancement-xyz-feature-name", True, "ENHANCEMENT-XYZ" + ) + assert not git_common.check_feature_branch( + "other-xyz-feature-name", True, "ENHANCEMENT-XYZ" + ) + def test_check_feature_branch_no_git_warns_but_passes(self, git_common, capsys): assert git_common.check_feature_branch("main", False) is True assert "skipped branch validation" in capsys.readouterr().err diff --git a/tests/test_create_new_feature_python_parity.py b/tests/test_create_new_feature_python_parity.py index 6cc50d80eb..41548046fe 100644 --- a/tests/test_create_new_feature_python_parity.py +++ b/tests/test_create_new_feature_python_parity.py @@ -224,6 +224,135 @@ def test_all_variants_timestamp_mode_match_shape(repo: Path) -> None: assert data["BRANCH_NAME"].startswith(data["FEATURE_NUM"]) +@requires_bash +def test_all_variants_custom_feature_id_normalizes_to_lowercase(repo: Path) -> None: + args = ( + "--json", + "--dry-run", + "--feature-id", + "ENHANCEMENT-XYZ", + "--short-name", + "user-auth", + "x", + ) + bash = run(bash_cmd(repo, SCRIPT, *args), repo) + py = run(py_cmd(repo, SCRIPT, *args), repo) + results = [bash, py] + if HAS_POWERSHELL: + results.append( + run( + ps_cmd( + repo, + SCRIPT, + "-Json", + "-DryRun", + "-FeatureId", + "ENHANCEMENT-XYZ", + "-ShortName", + "user-auth", + "x", + ), + repo, + ) + ) + + assert all(result.returncode == 0 for result in results) + assert all( + json_stdout(result)["BRANCH_NAME"] == "enhancement-xyz-user-auth" + for result in results + ) + assert all( + json_stdout(result)["FEATURE_NUM"] == "enhancement-xyz" + for result in results + ) + + +@requires_bash +def test_custom_feature_id_environment_matches_flag(repo: Path) -> None: + env = clean_env() + env["FEATURE_ID"] = "WORK_ITEM.42" + bash = run( + bash_cmd(repo, SCRIPT, "--json", "--dry-run", "--short-name", "sync", "x"), + repo, + env=env, + ) + py = run( + py_cmd(repo, SCRIPT, "--json", "--dry-run", "--short-name", "sync", "x"), + repo, + env=env, + ) + + assert bash.returncode == py.returncode == 0 + assert json_stdout(bash) == json_stdout(py) + assert json_stdout(py)["BRANCH_NAME"] == "work_item.42-sync" + + +@requires_bash +def test_custom_feature_id_full_run_creates_matching_directory( + repo_pair: tuple[Path, Path], +) -> None: + repo_a, repo_b = repo_pair + args = ( + "--json", + "--feature-id", + "ENHANCEMENT-XYZ", + "--short-name", + "user-auth", + "x", + ) + bash = run(bash_cmd(repo_a, SCRIPT, *args), repo_a) + py = run(py_cmd(repo_b, SCRIPT, *args), repo_b) + + assert bash.returncode == py.returncode == 0 + assert normalize_repo_paths(py.stdout, repo_b) == normalize_repo_paths( + bash.stdout, repo_a + ) + for current in repo_pair: + assert ( + current / "specs" / "enhancement-xyz-user-auth" / "spec.md" + ).is_file() + assert ( + current / ".specify" / "feature.json" + ).read_text(encoding="utf-8") == ( + '{"feature_directory":"specs/enhancement-xyz-user-auth"}\n' + ) + + +@requires_bash +@pytest.mark.parametrize("feature_id", ["../BAD", "-BAD", "BAD/", "BAD value"]) +def test_custom_feature_id_rejects_unsafe_values( + repo: Path, feature_id: str +) -> None: + args = ("--json", "--dry-run", "--feature-id", feature_id, "x") + bash = run(bash_cmd(repo, SCRIPT, *args), repo) + py = run(py_cmd(repo, SCRIPT, *args), repo) + + assert bash.returncode == py.returncode == 1 + assert bash.stdout == py.stdout == "" + assert "feature identifier must start and end" in bash.stderr + assert py.stderr == bash.stderr + + +@requires_bash +def test_custom_feature_id_conflict_fails_instead_of_renumbering(repo: Path) -> None: + (repo / "specs" / "enhancement-xyz-existing").mkdir(parents=True) + args = ( + "--json", + "--dry-run", + "--feature-id", + "ENHANCEMENT-XYZ", + "--short-name", + "new", + "x", + ) + bash = run(bash_cmd(repo, SCRIPT, *args), repo) + py = run(py_cmd(repo, SCRIPT, *args), repo) + + assert bash.returncode == py.returncode == 1 + assert "conflicts with an existing spec directory" in bash.stderr + assert py.stderr == bash.stderr + + @requires_bash @pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available") def test_all_variants_timestamp_number_warning_matches(repo: Path) -> None: diff --git a/tests/test_specify_template_numbering.py b/tests/test_specify_template_numbering.py index cce8991d90..9ee617802d 100644 --- a/tests/test_specify_template_numbering.py +++ b/tests/test_specify_template_numbering.py @@ -37,3 +37,12 @@ def test_main_execution_list_is_sequential(): assert ordinals, "Could not find the main execution list in specify.md" assert ordinals == list(range(1, 9)) + + +def test_custom_feature_identifier_precedes_numbering(): + """The command must keep branch and folder prefixes aligned for custom IDs.""" + text = SPECIFY_TEMPLATE.read_text(encoding="utf-8") + + assert "FEATURE_ID=ENHANCEMENT-XYZ" in text + assert "enhancement-xyz-user-auth" in text + assert "custom identifiers are never silently changed" in text