mirror of
https://github.com/github/spec-kit.git
synced 2026-03-24 22:33:08 +00:00
feat: create extensions/git with manifest, commands, scripts, and auto-install in init
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/809a1dbf-1301-4312-b4d2-e18f9b3e8b2f
This commit is contained in:
committed by
GitHub
parent
e9c464db14
commit
4f83308540
85
extensions/git/README.md
Normal file
85
extensions/git/README.md
Normal file
@@ -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)
|
||||||
66
extensions/git/commands/speckit.git.feature.md
Normal file
66
extensions/git/commands/speckit.git.feature.md
Normal file
@@ -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 "<short-name>" "<feature description>"`
|
||||||
|
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
||||||
|
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
||||||
|
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
||||||
|
|
||||||
|
**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
|
||||||
45
extensions/git/commands/speckit.git.remote.md
Normal file
45
extensions/git/commands/speckit.git.remote.md
Normal file
@@ -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/<owner>/<repo>.git`
|
||||||
|
- SSH: `git@github.com:<owner>/<repo>.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
|
||||||
49
extensions/git/commands/speckit.git.validate.md
Normal file
49
extensions/git/commands/speckit.git.validate.md
Normal file
@@ -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: <branch-name>`
|
||||||
|
- Check if the corresponding spec directory exists under `specs/`:
|
||||||
|
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the `###` portion
|
||||||
|
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
||||||
|
- If spec directory exists: `✓ Spec directory found: <path>`
|
||||||
|
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
||||||
|
|
||||||
|
If NOT on a feature branch:
|
||||||
|
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
||||||
|
- 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
|
||||||
12
extensions/git/config-template.yml
Normal file
12
extensions/git/config-template.yml
Normal file
@@ -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
|
||||||
49
extensions/git/extension.yml
Normal file
49
extensions/git/extension.yml
Normal file
@@ -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
|
||||||
353
extensions/git/scripts/bash/create-new-feature.sh
Normal file
353
extensions/git/scripts/bash/create-new-feature.sh
Normal file
@@ -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 <name>] [--number N] [--timestamp] <feature_description>"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " --json Output in JSON format"
|
||||||
|
echo " --short-name <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 <name>] [--number N] [--timestamp] <feature_description>" >&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
|
||||||
30
extensions/git/scripts/bash/git-common.sh
Normal file
30
extensions/git/scripts/bash/git-common.sh
Normal file
@@ -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
|
||||||
|
}
|
||||||
329
extensions/git/scripts/powershell/create-new-feature.ps1
Normal file
329
extensions/git/scripts/powershell/create-new-feature.ps1
Normal file
@@ -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 <name>] [-Number N] [-Timestamp] <feature description>"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Options:"
|
||||||
|
Write-Host " -Json Output in JSON format"
|
||||||
|
Write-Host " -ShortName <name> 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 <name>] [-Number N] [-Timestamp] <feature description>"
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
33
extensions/git/scripts/powershell/git-common.ps1
Normal file
33
extensions/git/scripts/powershell/git-common.ps1
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ packages = ["src/specify_cli"]
|
|||||||
"templates/commands" = "specify_cli/core_pack/commands"
|
"templates/commands" = "specify_cli/core_pack/commands"
|
||||||
"scripts/bash" = "specify_cli/core_pack/scripts/bash"
|
"scripts/bash" = "specify_cli/core_pack/scripts/bash"
|
||||||
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
|
"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.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"
|
".github/workflows/scripts/create-release-packages.ps1" = "specify_cli/core_pack/release_scripts/create-release-packages.ps1"
|
||||||
|
|
||||||
|
|||||||
@@ -1159,6 +1159,57 @@ def _locate_core_pack() -> Path | None:
|
|||||||
return 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]:
|
def _locate_release_script() -> tuple[Path, str]:
|
||||||
"""Return (script_path, shell_cmd) for the platform-appropriate release script.
|
"""Return (script_path, shell_cmd) for the platform-appropriate release script.
|
||||||
|
|
||||||
@@ -2176,6 +2227,11 @@ def init(
|
|||||||
"speckit_version": get_speckit_version(),
|
"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
|
# Install preset if specified
|
||||||
if preset:
|
if preset:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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:
|
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/<short-name>/`)
|
||||||
|
- 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 `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation
|
||||||
- If `"sequential"` or absent, do not add any extra flag (default behavior)
|
- 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 example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"`
|
||||||
- Bash (timestamp): `{SCRIPT} --json --timestamp --short-name "user-auth" "Add user authentication"`
|
- Bash (timestamp): `{SCRIPT} --json --timestamp --short-name "user-auth" "Add user authentication"`
|
||||||
- PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"`
|
- PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"`
|
||||||
|
|||||||
Reference in New Issue
Block a user