diff --git a/extensions/git/README.md b/extensions/git/README.md new file mode 100644 index 00000000..9ec18cb5 --- /dev/null +++ b/extensions/git/README.md @@ -0,0 +1,85 @@ +# Git Branching Workflow Extension + +Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection for Spec Kit. + +## Overview + +This extension provides Git branching operations as an optional, self-contained module. It manages: + +- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering +- **Branch validation** to ensure branches follow naming conventions +- **Git remote detection** for GitHub integration (e.g., issue creation) + +## Commands + +| Command | Description | +|---------|-------------| +| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering | +| `speckit.git.validate` | Validate current branch follows feature branch naming conventions | +| `speckit.git.remote` | Detect Git remote URL for GitHub integration | + +## Hooks + +| Event | Command | Optional | Description | +|-------|---------|----------|-------------| +| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification | +| `after_implement` | `speckit.git.validate` | Yes | Validate branch naming after implementation | + +## Configuration + +Configuration is stored in `.specify/extensions/git/git-config.yml`: + +```yaml +# Branch numbering strategy: "sequential" or "timestamp" +branch_numbering: sequential + +# Branch name template +branch_template: "{number}-{short_name}" + +# Whether to fetch remotes before computing next branch number +auto_fetch: true +``` + +### Environment Variable Override + +Set `SPECKIT_GIT_BRANCH_NUMBERING` to override the `branch_numbering` config value: + +```bash +export SPECKIT_GIT_BRANCH_NUMBERING=timestamp +``` + +## Installation + +```bash +# Install from the bundled extension +specify extension add git --from extensions/git/ + +# Or it auto-installs during specify init (migration period) +``` + +## Disabling + +```bash +# Disable the git extension (spec creation continues without branching) +specify extension disable git + +# Re-enable it +specify extension enable git +``` + +## Graceful Degradation + +When Git is not installed or the directory is not a Git repository: +- Spec directories are still created under `specs/` +- Branch creation is skipped with a warning +- Branch validation is skipped with a warning +- Remote detection returns empty results + +## Scripts + +The extension bundles cross-platform scripts: + +- `scripts/bash/create-new-feature.sh` — Bash implementation +- `scripts/bash/git-common.sh` — Shared Git utilities (Bash) +- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation +- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell) diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md new file mode 100644 index 00000000..13a7d078 --- /dev/null +++ b/extensions/git/commands/speckit.git.feature.md @@ -0,0 +1,66 @@ +--- +description: "Create a feature branch with sequential or timestamp numbering" +--- + +# Create Feature Branch + +Create a new feature branch for the given specification. + +## User Input + +```text +$ARGUMENTS +``` + +You **MUST** consider the user input before proceeding (if not empty). + +## Prerequisites + +- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, warn the user and skip branch creation (spec directory will still be created) + +## Branch Numbering Mode + +Determine the branch numbering strategy by checking configuration in this order: + +1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value +2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility) +3. Default to `sequential` if neither exists + +## Execution + +Generate a concise short name (2-4 words) for the branch: +- Analyze the feature description and extract the most meaningful keywords +- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug") +- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.) + +Run the appropriate script based on your platform: + +- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "" ""` +- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "" ""` +- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "" ""` +- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "" ""` + +**IMPORTANT**: +- Do NOT pass `--number` — the script determines the correct next number automatically +- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably +- You must only ever run this script once per feature +- The JSON output will contain BRANCH_NAME and SPEC_FILE paths + +If the extension scripts are not found at the `.specify/extensions/git/` path, fall back to: +- **Bash**: `scripts/bash/create-new-feature.sh` +- **PowerShell**: `scripts/powershell/create-new-feature.ps1` + +## Graceful Degradation + +If Git is not installed or the current directory is not a Git repository: +- The script will still create the spec directory under `specs/` +- A warning will be printed: `[specify] Warning: Git repository not detected; skipped branch creation` +- The workflow continues normally without branch creation + +## Output + +The script outputs JSON with: +- `BRANCH_NAME`: The created branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`) +- `SPEC_FILE`: Path to the created spec file +- `FEATURE_NUM`: The numeric or timestamp prefix used diff --git a/extensions/git/commands/speckit.git.remote.md b/extensions/git/commands/speckit.git.remote.md new file mode 100644 index 00000000..712a3e8b --- /dev/null +++ b/extensions/git/commands/speckit.git.remote.md @@ -0,0 +1,45 @@ +--- +description: "Detect Git remote URL for GitHub integration" +--- + +# Detect Git Remote URL + +Detect the Git remote URL for integration with GitHub services (e.g., issue creation). + +## Prerequisites + +- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, output a warning and return empty: + ``` + [specify] Warning: Git repository not detected; cannot determine remote URL + ``` + +## Execution + +Run the following command to get the remote URL: + +```bash +git config --get remote.origin.url +``` + +## Output + +Parse the remote URL and determine: + +1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`) +2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`) +3. **Is GitHub**: Whether the remote points to a GitHub repository + +Supported URL formats: +- HTTPS: `https://github.com//.git` +- SSH: `git@github.com:/.git` + +> [!CAUTION] +> ONLY report a GitHub repository if the remote URL actually points to github.com. +> Do NOT assume the remote is GitHub if the URL format doesn't match. + +## Graceful Degradation + +If Git is not installed, the directory is not a Git repository, or no remote is configured: +- Return an empty result +- Do NOT error — other workflows should continue without Git remote information diff --git a/extensions/git/commands/speckit.git.validate.md b/extensions/git/commands/speckit.git.validate.md new file mode 100644 index 00000000..cac3f4ab --- /dev/null +++ b/extensions/git/commands/speckit.git.validate.md @@ -0,0 +1,49 @@ +--- +description: "Validate current branch follows feature branch naming conventions" +--- + +# Validate Feature Branch + +Validate that the current Git branch follows the expected feature branch naming conventions. + +## Prerequisites + +- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` +- If Git is not available, output a warning and skip validation: + ``` + [specify] Warning: Git repository not detected; skipped branch validation + ``` + +## Validation Rules + +Get the current branch name: + +```bash +git rev-parse --abbrev-ref HEAD +``` + +The branch name must match one of these patterns: + +1. **Sequential**: `^[0-9]{3}-` (e.g., `001-feature-name`, `042-fix-bug`) +2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`) + +## Execution + +If on a feature branch (matches either pattern): +- Output: `✓ On feature branch: ` +- Check if the corresponding spec directory exists under `specs/`: + - For sequential branches, look for `specs/-*` where prefix matches the `###` portion + - For timestamp branches, look for `specs/-*` where prefix matches the `YYYYMMDD-HHMMSS` portion +- 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 or 20260319-143022-feature-name` + +## Graceful Degradation + +If Git is not installed or the directory is not a Git repository: +- Check the `SPECIFY_FEATURE` environment variable as a fallback +- If set, validate that value against the naming patterns +- If not set, skip validation with a warning diff --git a/extensions/git/config-template.yml b/extensions/git/config-template.yml new file mode 100644 index 00000000..95b3960d --- /dev/null +++ b/extensions/git/config-template.yml @@ -0,0 +1,12 @@ +# Git Branching Workflow Extension Configuration +# Copy this file to .specify/extensions/git/git-config.yml to customize + +# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS) +branch_numbering: sequential + +# Branch name template (used with sequential numbering) +# Available placeholders: {number}, {short_name} +branch_template: "{number}-{short_name}" + +# Whether to run `git fetch --all --prune` before computing the next branch number +auto_fetch: true diff --git a/extensions/git/extension.yml b/extensions/git/extension.yml new file mode 100644 index 00000000..ab5b68ea --- /dev/null +++ b/extensions/git/extension.yml @@ -0,0 +1,49 @@ +schema_version: "1.0" + +extension: + id: git + name: "Git Branching Workflow" + version: "1.0.0" + description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection" + author: spec-kit-core + repository: https://github.com/github/spec-kit + license: MIT + +requires: + speckit_version: ">=0.2.0" + tools: + - name: git + required: false + +provides: + commands: + - name: speckit.git.feature + file: commands/speckit.git.feature.md + description: "Create a feature branch with sequential or timestamp numbering" + - name: speckit.git.validate + file: commands/speckit.git.validate.md + description: "Validate current branch follows feature branch naming conventions" + - name: speckit.git.remote + file: commands/speckit.git.remote.md + description: "Detect Git remote URL for GitHub integration" + +hooks: + before_specify: + command: speckit.git.feature + optional: false + description: "Create feature branch before specification" + after_implement: + command: speckit.git.validate + optional: true + prompt: "Verify feature branch naming?" + description: "Validate branch naming after implementation" + +tags: + - "git" + - "branching" + - "workflow" + +defaults: + branch_numbering: sequential + branch_template: "{number}-{short_name}" + auto_fetch: true diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh new file mode 100644 index 00000000..0df4adc3 --- /dev/null +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -0,0 +1,353 @@ +#!/usr/bin/env bash + +set -e + +JSON_MODE=false +SHORT_NAME="" +BRANCH_NUMBER="" +USE_TIMESTAMP=false +ARGS=() +i=1 +while [ $i -le $# ]; do + arg="${!i}" + case "$arg" in + --json) + JSON_MODE=true + ;; + --short-name) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + # Check if the next argument is another option (starts with --) + if [[ "$next_arg" == --* ]]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + SHORT_NAME="$next_arg" + ;; + --number) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + BRANCH_NUMBER="$next_arg" + ;; + --timestamp) + USE_TIMESTAMP=true + ;; + --help|-h) + echo "Usage: $0 [--json] [--short-name ] [--number N] [--timestamp] " + echo "" + echo "Options:" + echo " --json Output in JSON format" + 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 " --help, -h Show this help message" + 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'" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac + i=$((i + 1)) +done + +FEATURE_DESCRIPTION="${ARGS[*]}" +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Usage: $0 [--json] [--short-name ] [--number N] [--timestamp] " >&2 + exit 1 +fi + +# Trim whitespace and validate description is not empty (e.g., user passed only whitespace) +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs) +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Error: Feature description cannot be empty or contain only whitespace" >&2 + exit 1 +fi + +# Function to find the repository root by searching for existing project markers +find_repo_root() { + local dir="$1" + while [ "$dir" != "/" ]; do + if [ -d "$dir/.git" ] || [ -d "$dir/.specify" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +# Function to get highest number from specs directory +get_highest_from_specs() { + local specs_dir="$1" + local highest=0 + + if [ -d "$specs_dir" ]; then + for dir in "$specs_dir"/*; do + [ -d "$dir" ] || continue + dirname=$(basename "$dir") + # Only match sequential prefixes (###-*), skip timestamp dirs + if echo "$dirname" | grep -q '^[0-9]\{3\}-'; then + number=$(echo "$dirname" | grep -o '^[0-9]\{3\}') + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done + fi + + echo "$highest" +} + +# Function to get highest number from git branches +get_highest_from_branches() { + local highest=0 + + # Get all branches (local and remote) + branches=$(git branch -a 2>/dev/null || echo "") + + if [ -n "$branches" ]; then + while IFS= read -r branch; do + # Clean branch name: remove leading markers and remote prefixes + clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||') + + # Extract feature number if branch matches pattern ###-* + if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then + number=$(echo "$clean_branch" | grep -o '^[0-9]\{3\}' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + done <<< "$branches" + fi + + echo "$highest" +} + +# Function to check existing branches (local and remote) and return next available number +check_existing_branches() { + local specs_dir="$1" + + # Fetch all remotes to get latest branch info (suppress errors if no remotes) + git fetch --all --prune >/dev/null 2>&1 || true + + # Get highest number from ALL branches (not just matching short name) + local highest_branch=$(get_highest_from_branches) + + # Get highest number from ALL specs (not just matching short name) + local highest_spec=$(get_highest_from_specs "$specs_dir") + + # Take the maximum of both + local max_num=$highest_branch + if [ "$highest_spec" -gt "$max_num" ]; then + max_num=$highest_spec + fi + + # Return next number + echo $((max_num + 1)) +} + +# Function to clean and format a branch name +clean_branch_name() { + local name="$1" + echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' +} + +# Resolve repository root. Prefer git information when available, but fall back +# to searching for repository markers so the workflow still functions in repositories that +# were initialised with --no-git. +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +if git rev-parse --show-toplevel >/dev/null 2>&1; then + REPO_ROOT=$(git rev-parse --show-toplevel) + HAS_GIT=true +else + REPO_ROOT="$(find_repo_root "$SCRIPT_DIR")" + if [ -z "$REPO_ROOT" ]; then + echo "Error: Could not determine repository root. Please run this script from within the repository." >&2 + exit 1 + fi + HAS_GIT=false +fi + +cd "$REPO_ROOT" + +SPECS_DIR="$REPO_ROOT/specs" +mkdir -p "$SPECS_DIR" + +# Function to generate branch name with stop word filtering and length filtering +generate_branch_name() { + local description="$1" + + # Common stop words to filter out + local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$" + + # Convert to lowercase and split into words + local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') + + # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) + local meaningful_words=() + for word in $clean_name; do + # Skip empty words + [ -z "$word" ] && continue + + # Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms) + if ! echo "$word" | grep -qiE "$stop_words"; then + if [ ${#word} -ge 3 ]; then + meaningful_words+=("$word") + elif echo "$description" | grep -q "\b${word^^}\b"; then + # Keep short words if they appear as uppercase in original (likely acronyms) + meaningful_words+=("$word") + fi + fi + done + + # If we have meaningful words, use first 3-4 of them + if [ ${#meaningful_words[@]} -gt 0 ]; then + local max_words=3 + if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi + + local result="" + local count=0 + for word in "${meaningful_words[@]}"; do + if [ $count -ge $max_words ]; then break; fi + if [ -n "$result" ]; then result="$result-"; fi + result="$result$word" + count=$((count + 1)) + done + echo "$result" + else + # Fallback to original logic if no meaningful words found + local cleaned=$(clean_branch_name "$description") + echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//' + fi +} + +# Generate branch name +if [ -n "$SHORT_NAME" ]; then + # Use provided short name, just clean it up + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") +else + # Generate from description with smart filtering + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") +fi + +# Warn if --number and --timestamp are both specified +if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" + BRANCH_NUMBER="" +fi + +# Determine branch prefix +if [ "$USE_TIMESTAMP" = true ]; then + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +else + # Determine branch number + if [ -z "$BRANCH_NUMBER" ]; then + if [ "$HAS_GIT" = true ]; then + # Check existing branches on remotes + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") + else + # Fall back to local directory check + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + fi + 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}" +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 prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 + PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) + MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) + + # Truncate suffix at word boundary if possible + TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) + # Remove trailing hyphen if truncation created one + TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') + + ORIGINAL_BRANCH_NAME="$BRANCH_NAME" + BRANCH_NAME="${FEATURE_NUM}-${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)" + >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" +fi + +if [ "$HAS_GIT" = true ]; then + if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then + # Check if branch already exists + if git branch --list "$BRANCH_NAME" | grep -q .; then + if [ "$USE_TIMESTAMP" = true ]; then + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." + else + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." + fi + exit 1 + else + >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again." + exit 1 + fi + fi +else + >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" +fi + +FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +mkdir -p "$FEATURE_DIR" + +TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true +SPEC_FILE="$FEATURE_DIR/spec.md" +if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then + cp "$TEMPLATE" "$SPEC_FILE" +else + echo "Warning: Spec template not found; created empty spec file" >&2 + touch "$SPEC_FILE" +fi + +# Inform the user how to persist the feature variable in their own shell +printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 + +if $JSON_MODE; then + if command -v jq >/dev/null 2>&1; then + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' + else + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + fi +else + echo "BRANCH_NAME: $BRANCH_NAME" + echo "SPEC_FILE: $SPEC_FILE" + echo "FEATURE_NUM: $FEATURE_NUM" + printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" +fi diff --git a/extensions/git/scripts/bash/git-common.sh b/extensions/git/scripts/bash/git-common.sh new file mode 100644 index 00000000..4da1c5ca --- /dev/null +++ b/extensions/git/scripts/bash/git-common.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Git-specific common functions for the git extension. +# Extracted from scripts/bash/common.sh — contains only git-specific +# branch validation and detection logic. + +# Check if we have git available +has_git() { + git rev-parse --show-toplevel >/dev/null 2>&1 +} + +# Validate that a branch name matches the expected feature branch pattern. +# Accepts sequential (###-*) or timestamp (YYYYMMDD-HHMMSS-*) formats. +check_feature_branch() { + local branch="$1" + local has_git_repo="$2" + + # For non-git repos, we can't enforce branch naming but still provide output + if [[ "$has_git_repo" != "true" ]]; then + echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2 + return 0 + fi + + if [[ ! "$branch" =~ ^[0-9]{3}- ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 + echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2 + return 1 + fi + + return 0 +} diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 new file mode 100644 index 00000000..473c925b --- /dev/null +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -0,0 +1,329 @@ +#!/usr/bin/env pwsh +# Create a new feature +[CmdletBinding()] +param( + [switch]$Json, + [string]$ShortName, + [Parameter()] + [int]$Number = 0, + [switch]$Timestamp, + [switch]$Help, + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] + [string[]]$FeatureDescription +) +$ErrorActionPreference = 'Stop' + +# Show help if requested +if ($Help) { + Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] [-Number N] [-Timestamp] " + Write-Host "" + Write-Host "Options:" + Write-Host " -Json Output in JSON format" + 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 " -Help Show this help message" + 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'" + exit 0 +} + +# Check if feature description provided +if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { + Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName ] [-Number N] [-Timestamp] " + exit 1 +} + +$featureDesc = ($FeatureDescription -join ' ').Trim() + +# Validate description is not empty after trimming (e.g., user passed only whitespace) +if ([string]::IsNullOrWhiteSpace($featureDesc)) { + Write-Error "Error: Feature description cannot be empty or contain only whitespace" + exit 1 +} + +# Resolve repository root. Prefer git information when available, but fall back +# to searching for repository markers so the workflow still functions in repositories that +# were initialized with --no-git. +function Find-RepositoryRoot { + param( + [string]$StartDir, + [string[]]$Markers = @('.git', '.specify') + ) + $current = Resolve-Path $StartDir + while ($true) { + foreach ($marker in $Markers) { + if (Test-Path (Join-Path $current $marker)) { + return $current + } + } + $parent = Split-Path $current -Parent + if ($parent -eq $current) { + # Reached filesystem root without finding markers + return $null + } + $current = $parent + } +} + +function Get-HighestNumberFromSpecs { + param([string]$SpecsDir) + + $highest = 0 + if (Test-Path $SpecsDir) { + Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object { + if ($_.Name -match '^(\d{3})-') { + $num = [int]$matches[1] + if ($num -gt $highest) { $highest = $num } + } + } + } + return $highest +} + +function Get-HighestNumberFromBranches { + param() + + $highest = 0 + try { + $branches = git branch -a 2>$null + if ($LASTEXITCODE -eq 0) { + foreach ($branch in $branches) { + # Clean branch name: remove leading markers and remote prefixes + $cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' + + # Extract feature number if branch matches pattern ###-* + if ($cleanBranch -match '^(\d{3})-') { + $num = [int]$matches[1] + if ($num -gt $highest) { $highest = $num } + } + } + } + } catch { + # If git command fails, return 0 + Write-Verbose "Could not check Git branches: $_" + } + return $highest +} + +function Get-NextBranchNumber { + param( + [string]$SpecsDir + ) + + # Fetch all remotes to get latest branch info (suppress errors if no remotes) + try { + git fetch --all --prune 2>$null | Out-Null + } catch { + # Ignore fetch errors + } + + # Get highest number from ALL branches (not just matching short name) + $highestBranch = Get-HighestNumberFromBranches + + # Get highest number from ALL specs (not just matching short name) + $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir + + # Take the maximum of both + $maxNum = [Math]::Max($highestBranch, $highestSpec) + + # Return next number + return $maxNum + 1 +} + +function ConvertTo-CleanBranchName { + param([string]$Name) + + return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' +} +$fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot) +if (-not $fallbackRoot) { + Write-Error "Error: Could not determine repository root. Please run this script from within the repository." + exit 1 +} + +# Load common functions (includes Resolve-Template) +. "$PSScriptRoot/common.ps1" + +try { + $repoRoot = git rev-parse --show-toplevel 2>$null + if ($LASTEXITCODE -eq 0) { + $hasGit = $true + } else { + throw "Git not available" + } +} catch { + $repoRoot = $fallbackRoot + $hasGit = $false +} + +Set-Location $repoRoot + +$specsDir = Join-Path $repoRoot 'specs' +New-Item -ItemType Directory -Path $specsDir -Force | Out-Null + +# Function to generate branch name with stop word filtering and length filtering +function Get-BranchName { + param([string]$Description) + + # Common stop words to filter out + $stopWords = @( + 'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from', + 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', + 'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall', + 'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their', + 'want', 'need', 'add', 'get', 'set' + ) + + # Convert to lowercase and extract words (alphanumeric only) + $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' ' + $words = $cleanName -split '\s+' | Where-Object { $_ } + + # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) + $meaningfulWords = @() + foreach ($word in $words) { + # Skip stop words + if ($stopWords -contains $word) { continue } + + # Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms) + if ($word.Length -ge 3) { + $meaningfulWords += $word + } elseif ($Description -match "\b$($word.ToUpper())\b") { + # Keep short words if they appear as uppercase in original (likely acronyms) + $meaningfulWords += $word + } + } + + # If we have meaningful words, use first 3-4 of them + if ($meaningfulWords.Count -gt 0) { + $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 } + $result = ($meaningfulWords | Select-Object -First $maxWords) -join '-' + return $result + } else { + # Fallback to original logic if no meaningful words found + $result = ConvertTo-CleanBranchName -Name $Description + $fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3 + return [string]::Join('-', $fallbackWords) + } +} + +# Generate branch name +if ($ShortName) { + # Use provided short name, just clean it up + $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName +} else { + # Generate from description with smart filtering + $branchSuffix = Get-BranchName -Description $featureDesc +} + +# Warn if -Number and -Timestamp are both specified +if ($Timestamp -and $Number -ne 0) { + Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" + $Number = 0 +} + +# Determine branch prefix +if ($Timestamp) { + $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' + $branchName = "$featureNum-$branchSuffix" +} else { + # Determine branch number + if ($Number -eq 0) { + if ($hasGit) { + # Check existing branches on remotes + $Number = Get-NextBranchNumber -SpecsDir $specsDir + } else { + # Fall back to local directory check + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } + } + + $featureNum = ('{0:000}' -f $Number) + $branchName = "$featureNum-$branchSuffix" +} + +# 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 prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 + $prefixLength = $featureNum.Length + 1 + $maxSuffixLength = $maxBranchLength - $prefixLength + + # Truncate suffix + $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) + # Remove trailing hyphen if truncation created one + $truncatedSuffix = $truncatedSuffix -replace '-$', '' + + $originalBranchName = $branchName + $branchName = "$featureNum-$truncatedSuffix" + + Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" + Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" + Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" +} + +if ($hasGit) { + $branchCreated = $false + try { + git checkout -q -b $branchName 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + $branchCreated = $true + } + } catch { + # Exception during git command + } + + if (-not $branchCreated) { + # Check if branch already exists + $existingBranch = git branch --list $branchName 2>$null + if ($existingBranch) { + if ($Timestamp) { + Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName." + } else { + Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." + } + exit 1 + } else { + Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." + exit 1 + } + } +} else { + Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" +} + +$featureDir = Join-Path $specsDir $branchName +New-Item -ItemType Directory -Path $featureDir -Force | Out-Null + +$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot +$specFile = Join-Path $featureDir 'spec.md' +if ($template -and (Test-Path $template)) { + Copy-Item $template $specFile -Force +} else { + New-Item -ItemType File -Path $specFile | Out-Null +} + +# Set the SPECIFY_FEATURE environment variable for the current session +$env:SPECIFY_FEATURE = $branchName + +if ($Json) { + $obj = [PSCustomObject]@{ + BRANCH_NAME = $branchName + SPEC_FILE = $specFile + FEATURE_NUM = $featureNum + HAS_GIT = $hasGit + } + $obj | ConvertTo-Json -Compress +} else { + Write-Output "BRANCH_NAME: $branchName" + Write-Output "SPEC_FILE: $specFile" + Write-Output "FEATURE_NUM: $featureNum" + Write-Output "HAS_GIT: $hasGit" + Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" +} + diff --git a/extensions/git/scripts/powershell/git-common.ps1 b/extensions/git/scripts/powershell/git-common.ps1 new file mode 100644 index 00000000..b5938aa2 --- /dev/null +++ b/extensions/git/scripts/powershell/git-common.ps1 @@ -0,0 +1,33 @@ +#!/usr/bin/env pwsh +# Git-specific common functions for the git extension. +# Extracted from scripts/powershell/common.ps1 — contains only git-specific +# branch validation and detection logic. + +function Test-HasGit { + try { + git rev-parse --show-toplevel 2>$null | Out-Null + return ($LASTEXITCODE -eq 0) + } catch { + return $false + } +} + +function Test-FeatureBranch { + param( + [string]$Branch, + [bool]$HasGit = $true + ) + + # For non-git repos, we can't enforce branch naming but still provide output + if (-not $HasGit) { + Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation" + return $true + } + + if ($Branch -notmatch '^[0-9]{3}-' -and $Branch -notmatch '^\d{8}-\d{6}-') { + Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" + Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" + return $false + } + return $true +} diff --git a/pyproject.toml b/pyproject.toml index dfc3c92e..431d45e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ packages = ["src/specify_cli"] "templates/commands" = "specify_cli/core_pack/commands" "scripts/bash" = "specify_cli/core_pack/scripts/bash" "scripts/powershell" = "specify_cli/core_pack/scripts/powershell" +"extensions/git" = "specify_cli/core_pack/extensions/git" ".github/workflows/scripts/create-release-packages.sh" = "specify_cli/core_pack/release_scripts/create-release-packages.sh" ".github/workflows/scripts/create-release-packages.ps1" = "specify_cli/core_pack/release_scripts/create-release-packages.ps1" diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 08af6510..e6a7fde5 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1159,6 +1159,57 @@ def _locate_core_pack() -> Path | None: return None +def _locate_bundled_git_extension() -> Path | None: + """Return the path to the bundled git extension, or None. + + Checks the wheel's core_pack first, then falls back to the + source-checkout ``extensions/git/`` directory. + """ + core = _locate_core_pack() + if core is not None: + candidate = core / "extensions" / "git" + if (candidate / "extension.yml").is_file(): + return candidate + + # Source-checkout / editable install: look relative to repo root + repo_root = Path(__file__).parent.parent.parent + candidate = repo_root / "extensions" / "git" + if (candidate / "extension.yml").is_file(): + return candidate + + return None + + +def _install_bundled_git_extension(project_path: Path) -> bool: + """Auto-install the bundled git extension during ``specify init``. + + This is a migration-period mechanism (pre-1.0.0) that auto-enables + the git extension so that existing branching workflows continue to work. + Before 1.0.0, this auto-install will be removed and the extension will + become opt-in. + + Returns True if the extension was installed, False otherwise. + """ + ext_source = _locate_bundled_git_extension() + if ext_source is None: + return False + + try: + from .extensions import ExtensionManager, ExtensionError + manager = ExtensionManager(project_path) + + # Skip if already installed (e.g. via preset) + if manager.registry.is_installed("git"): + return True + + speckit_ver = get_speckit_version() + manager.install_from_directory(ext_source, speckit_ver) + return True + except Exception: + # Non-fatal: branching still works via core scripts during migration + return False + + def _locate_release_script() -> tuple[Path, str]: """Return (script_path, shell_cmd) for the platform-appropriate release script. @@ -2176,6 +2227,11 @@ def init( "speckit_version": get_speckit_version(), }) + # Auto-install the bundled git extension (migration period, pre-1.0.0). + # This preserves backward compatibility for existing branching workflows. + # Before 1.0.0, this will be removed and git becomes opt-in. + _install_bundled_git_extension(project_path) + # Install preset if specified if preset: try: diff --git a/templates/commands/specify.md b/templates/commands/specify.md index a81b8f12..d5afd740 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -75,10 +75,23 @@ Given that feature description, do this: 2. **Create the feature branch** by running the script with `--short-name` (and `--json`). In sequential mode, do NOT pass `--number` — the script auto-detects the next available number. In timestamp mode, the script generates a `YYYYMMDD-HHMMSS` prefix automatically: - **Branch numbering mode**: Before running the script, check if `.specify/init-options.json` exists and read the `branch_numbering` value. + **Git extension check**: Before running the branch creation script, check if the git extension is enabled: + - Check if `.specify/extensions/.registry/git.json` exists + - If it exists, read it and verify `"enabled"` is `true` (or not explicitly set to `false`) + - If the git extension is **disabled** or the registry file does not exist, **skip branch creation entirely** — proceed directly to step 3 using a spec directory name derived from the short name (e.g., `specs//`) + - If the git extension is enabled (or the registry file doesn't exist but `.specify/init-options.json` has `branch_numbering` set), proceed with branch creation below + + **Branch numbering mode**: Before running the script, determine the branch numbering strategy: + 1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value (extension config takes precedence) + 2. If not found, check `.specify/init-options.json` for `branch_numbering` value (backward compatibility) + 3. Default to `sequential` if neither exists - If `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation - If `"sequential"` or absent, do not add any extra flag (default behavior) + **Script resolution**: Use the extension's bundled scripts when available, falling back to core scripts: + - If `.specify/extensions/git/scripts/bash/create-new-feature.sh` exists, use it + - Otherwise, fall back to `{SCRIPT}` + - Bash example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"` - Bash (timestamp): `{SCRIPT} --json --timestamp --short-name "user-auth" "Add user authentication"` - PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"`