mirror of
https://github.com/github/spec-kit.git
synced 2026-03-20 20:33:08 +00:00
feat: add timestamp-based branch naming option for specify init (#1911)
* feat: add timestamp-based branch naming option for specify init Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Copilot feedback * Fix test * Copilot feedback * Update tests/test_branch_numbering.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -222,6 +222,7 @@ The `specify` command supports the following options:
|
||||
| `--debug` | Flag | Enable detailed debug output for troubleshooting |
|
||||
| `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) |
|
||||
| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`) |
|
||||
| `--branch-numbering` | Option | Branch numbering strategy: `sequential` (default — `001`, `002`, `003`) or `timestamp` (`YYYYMMDD-HHMMSS`). Timestamp mode is useful for distributed teams to avoid numbering conflicts |
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -296,6 +297,9 @@ specify init my-project --ai claude --ai-skills
|
||||
# Initialize in current directory with agent skills
|
||||
specify init --here --ai gemini --ai-skills
|
||||
|
||||
# Use timestamp-based branch numbering (useful for distributed teams)
|
||||
specify init my-project --ai claude --branch-numbering timestamp
|
||||
|
||||
# Check system requirements
|
||||
specify check
|
||||
```
|
||||
|
||||
@@ -33,16 +33,27 @@ get_current_branch() {
|
||||
if [[ -d "$specs_dir" ]]; then
|
||||
local latest_feature=""
|
||||
local highest=0
|
||||
local latest_timestamp=""
|
||||
|
||||
for dir in "$specs_dir"/*; do
|
||||
if [[ -d "$dir" ]]; then
|
||||
local dirname=$(basename "$dir")
|
||||
if [[ "$dirname" =~ ^([0-9]{3})- ]]; then
|
||||
if [[ "$dirname" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
|
||||
# Timestamp-based branch: compare lexicographically
|
||||
local ts="${BASH_REMATCH[1]}"
|
||||
if [[ "$ts" > "$latest_timestamp" ]]; then
|
||||
latest_timestamp="$ts"
|
||||
latest_feature=$dirname
|
||||
fi
|
||||
elif [[ "$dirname" =~ ^([0-9]{3})- ]]; then
|
||||
local number=${BASH_REMATCH[1]}
|
||||
number=$((10#$number))
|
||||
if [[ "$number" -gt "$highest" ]]; then
|
||||
highest=$number
|
||||
latest_feature=$dirname
|
||||
# Only update if no timestamp branch found yet
|
||||
if [[ -z "$latest_timestamp" ]]; then
|
||||
latest_feature=$dirname
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -72,9 +83,9 @@ check_feature_branch() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! "$branch" =~ ^[0-9]{3}- ]]; then
|
||||
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" >&2
|
||||
echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -90,15 +101,18 @@ find_feature_dir_by_prefix() {
|
||||
local branch_name="$2"
|
||||
local specs_dir="$repo_root/specs"
|
||||
|
||||
# Extract numeric prefix from branch (e.g., "004" from "004-whatever")
|
||||
if [[ ! "$branch_name" =~ ^([0-9]{3})- ]]; then
|
||||
# If branch doesn't have numeric prefix, fall back to exact match
|
||||
# Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches)
|
||||
local prefix=""
|
||||
if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
|
||||
prefix="${BASH_REMATCH[1]}"
|
||||
elif [[ "$branch_name" =~ ^([0-9]{3})- ]]; then
|
||||
prefix="${BASH_REMATCH[1]}"
|
||||
else
|
||||
# If branch doesn't have a recognized prefix, fall back to exact match
|
||||
echo "$specs_dir/$branch_name"
|
||||
return
|
||||
fi
|
||||
|
||||
local prefix="${BASH_REMATCH[1]}"
|
||||
|
||||
# Search for directories in specs/ that start with this prefix
|
||||
local matches=()
|
||||
if [[ -d "$specs_dir" ]]; then
|
||||
@@ -119,7 +133,7 @@ find_feature_dir_by_prefix() {
|
||||
else
|
||||
# Multiple matches - this shouldn't happen with proper naming convention
|
||||
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
|
||||
echo "Please ensure only one spec directory exists per numeric prefix." >&2
|
||||
echo "Please ensure only one spec directory exists per prefix." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ 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
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
;;
|
||||
--short-name)
|
||||
if [ $((i + 1)) -gt $# ]; then
|
||||
@@ -40,22 +41,27 @@ while [ $i -le $# ]; do
|
||||
fi
|
||||
BRANCH_NUMBER="$next_arg"
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--json] [--short-name <name>] [--number N] <feature_description>"
|
||||
--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")
|
||||
*)
|
||||
ARGS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
@@ -63,7 +69,7 @@ done
|
||||
|
||||
FEATURE_DESCRIPTION="${ARGS[*]}"
|
||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
echo "Usage: $0 [--json] [--short-name <name>] [--number N] <feature_description>" >&2
|
||||
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -96,10 +102,13 @@ get_highest_from_specs() {
|
||||
for dir in "$specs_dir"/*; do
|
||||
[ -d "$dir" ] || continue
|
||||
dirname=$(basename "$dir")
|
||||
number=$(echo "$dirname" | grep -o '^[0-9]\+' || echo "0")
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
# 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
|
||||
@@ -242,29 +251,42 @@ else
|
||||
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
|
||||
fi
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# 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}"
|
||||
# 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: feature number (3) + hyphen (1) = 4 chars
|
||||
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - 4))
|
||||
# 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)
|
||||
@@ -283,7 +305,11 @@ 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
|
||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
||||
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."
|
||||
|
||||
@@ -38,17 +38,28 @@ function Get-CurrentBranch {
|
||||
if (Test-Path $specsDir) {
|
||||
$latestFeature = ""
|
||||
$highest = 0
|
||||
|
||||
$latestTimestamp = ""
|
||||
|
||||
Get-ChildItem -Path $specsDir -Directory | ForEach-Object {
|
||||
if ($_.Name -match '^(\d{3})-') {
|
||||
if ($_.Name -match '^(\d{8}-\d{6})-') {
|
||||
# Timestamp-based branch: compare lexicographically
|
||||
$ts = $matches[1]
|
||||
if ($ts -gt $latestTimestamp) {
|
||||
$latestTimestamp = $ts
|
||||
$latestFeature = $_.Name
|
||||
}
|
||||
} elseif ($_.Name -match '^(\d{3})-') {
|
||||
$num = [int]$matches[1]
|
||||
if ($num -gt $highest) {
|
||||
$highest = $num
|
||||
$latestFeature = $_.Name
|
||||
# Only update if no timestamp branch found yet
|
||||
if (-not $latestTimestamp) {
|
||||
$latestFeature = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if ($latestFeature) {
|
||||
return $latestFeature
|
||||
}
|
||||
@@ -79,9 +90,9 @@ function Test-FeatureBranch {
|
||||
return $true
|
||||
}
|
||||
|
||||
if ($Branch -notmatch '^[0-9]{3}-') {
|
||||
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"
|
||||
Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name"
|
||||
return $false
|
||||
}
|
||||
return $true
|
||||
|
||||
@@ -6,6 +6,7 @@ param(
|
||||
[string]$ShortName,
|
||||
[Parameter()]
|
||||
[int]$Number = 0,
|
||||
[switch]$Timestamp,
|
||||
[switch]$Help,
|
||||
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
||||
[string[]]$FeatureDescription
|
||||
@@ -14,23 +15,25 @@ $ErrorActionPreference = 'Stop'
|
||||
|
||||
# Show help if requested
|
||||
if ($Help) {
|
||||
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] <feature description>"
|
||||
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>] <feature description>"
|
||||
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -72,7 +75,7 @@ function Get-HighestNumberFromSpecs {
|
||||
$highest = 0
|
||||
if (Test-Path $SpecsDir) {
|
||||
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
||||
if ($_.Name -match '^(\d+)') {
|
||||
if ($_.Name -match '^(\d{3})-') {
|
||||
$num = [int]$matches[1]
|
||||
if ($num -gt $highest) { $highest = $num }
|
||||
}
|
||||
@@ -93,7 +96,7 @@ function Get-HighestNumberFromBranches {
|
||||
$cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||
|
||||
# Extract feature number if branch matches pattern ###-*
|
||||
if ($cleanBranch -match '^(\d+)-') {
|
||||
if ($cleanBranch -match '^(\d{3})-') {
|
||||
$num = [int]$matches[1]
|
||||
if ($num -gt $highest) { $highest = $num }
|
||||
}
|
||||
@@ -216,27 +219,40 @@ if ($ShortName) {
|
||||
$branchSuffix = Get-BranchName -Description $featureDesc
|
||||
}
|
||||
|
||||
# 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
|
||||
}
|
||||
# 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
|
||||
}
|
||||
|
||||
$featureNum = ('{0:000}' -f $Number)
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
# 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: feature number (3) + hyphen (1) = 4 chars
|
||||
$maxSuffixLength = $maxBranchLength - 4
|
||||
# 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))
|
||||
@@ -266,7 +282,11 @@ if ($hasGit) {
|
||||
# Check if branch already exists
|
||||
$existingBranch = git branch --list $branchName 2>$null
|
||||
if ($existingBranch) {
|
||||
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
||||
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."
|
||||
|
||||
@@ -1479,6 +1479,7 @@ def init(
|
||||
github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"),
|
||||
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
|
||||
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
|
||||
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"),
|
||||
):
|
||||
"""
|
||||
Initialize a new Specify project from the latest template.
|
||||
@@ -1546,6 +1547,11 @@ def init(
|
||||
console.print("[yellow]Usage:[/yellow] specify init <project> --ai <agent> --ai-skills")
|
||||
raise typer.Exit(1)
|
||||
|
||||
BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"}
|
||||
if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES:
|
||||
console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if here:
|
||||
project_name = Path.cwd().name
|
||||
project_path = Path.cwd()
|
||||
@@ -1781,6 +1787,7 @@ def init(
|
||||
"ai": selected_ai,
|
||||
"ai_skills": ai_skills,
|
||||
"ai_commands_dir": ai_commands_dir,
|
||||
"branch_numbering": branch_numbering or "sequential",
|
||||
"here": here,
|
||||
"preset": preset,
|
||||
"script": selected_script,
|
||||
|
||||
@@ -73,10 +73,16 @@ Given that feature description, do this:
|
||||
- "Create a dashboard for analytics" → "analytics-dashboard"
|
||||
- "Fix payment processing timeout bug" → "fix-payment-timeout"
|
||||
|
||||
2. **Create the feature branch** by running the script with `--short-name` (and `--json`), and do NOT pass `--number` (the script auto-detects the next globally available number across all branches and spec directories):
|
||||
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.
|
||||
- If `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation
|
||||
- If `"sequential"` or absent, do not add any extra flag (default behavior)
|
||||
|
||||
- Bash example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"`
|
||||
- Bash (timestamp): `{SCRIPT} --json --timestamp --short-name "user-auth" "Add user authentication"`
|
||||
- PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"`
|
||||
- PowerShell (timestamp): `{SCRIPT} -Json -Timestamp -ShortName "user-auth" "Add user authentication"`
|
||||
|
||||
**IMPORTANT**:
|
||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
||||
|
||||
89
tests/test_branch_numbering.py
Normal file
89
tests/test_branch_numbering.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Unit tests for branch numbering options (sequential vs timestamp).
|
||||
|
||||
Tests cover:
|
||||
- Persisting branch_numbering in init-options.json
|
||||
- Default value when branch_numbering is None
|
||||
- Validation of branch_numbering values
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from specify_cli import save_init_options
|
||||
|
||||
|
||||
class TestSaveBranchNumbering:
|
||||
"""Tests for save_init_options with branch_numbering."""
|
||||
|
||||
def test_save_branch_numbering_timestamp(self, tmp_path: Path):
|
||||
opts = {"branch_numbering": "timestamp", "ai": "claude"}
|
||||
save_init_options(tmp_path, opts)
|
||||
|
||||
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
|
||||
assert saved["branch_numbering"] == "timestamp"
|
||||
|
||||
def test_save_branch_numbering_sequential(self, tmp_path: Path):
|
||||
opts = {"branch_numbering": "sequential", "ai": "claude"}
|
||||
save_init_options(tmp_path, opts)
|
||||
|
||||
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
|
||||
assert saved["branch_numbering"] == "sequential"
|
||||
|
||||
def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path, monkeypatch):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
def _fake_download(project_path, *args, **kwargs):
|
||||
Path(project_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download)
|
||||
|
||||
project_dir = tmp_path / "proj"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
saved = json.loads((project_dir / ".specify/init-options.json").read_text())
|
||||
assert saved["branch_numbering"] == "sequential"
|
||||
|
||||
|
||||
class TestBranchNumberingValidation:
|
||||
"""Tests for branch_numbering CLI validation via CliRunner."""
|
||||
|
||||
def test_invalid_branch_numbering_rejected(self, tmp_path: Path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "foobar"])
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid --branch-numbering" in result.output
|
||||
|
||||
def test_valid_branch_numbering_sequential(self, tmp_path: Path, monkeypatch):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
def _fake_download(project_path, *args, **kwargs):
|
||||
Path(project_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools"])
|
||||
assert result.exit_code == 0
|
||||
assert "Invalid --branch-numbering" not in (result.output or "")
|
||||
|
||||
def test_valid_branch_numbering_timestamp(self, tmp_path: Path, monkeypatch):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
def _fake_download(project_path, *args, **kwargs):
|
||||
Path(project_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools"])
|
||||
assert result.exit_code == 0
|
||||
assert "Invalid --branch-numbering" not in (result.output or "")
|
||||
252
tests/test_timestamp_branches.py
Normal file
252
tests/test_timestamp_branches.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Pytest tests for timestamp-based branch naming in create-new-feature.sh and common.sh.
|
||||
|
||||
Converted from tests/test_timestamp_branches.sh so they are discovered by `uv run pytest`.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh"
|
||||
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git_repo(tmp_path: Path) -> Path:
|
||||
"""Create a temp git repo with scripts and .specify dir."""
|
||||
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "--allow-empty", "-m", "init", "-q"],
|
||||
cwd=tmp_path,
|
||||
check=True,
|
||||
)
|
||||
scripts_dir = tmp_path / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
shutil.copy(CREATE_FEATURE, scripts_dir / "create-new-feature.sh")
|
||||
shutil.copy(COMMON_SH, scripts_dir / "common.sh")
|
||||
(tmp_path / ".specify" / "templates").mkdir(parents=True)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_git_dir(tmp_path: Path) -> Path:
|
||||
"""Create a temp directory without git, but with scripts."""
|
||||
scripts_dir = tmp_path / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
shutil.copy(CREATE_FEATURE, scripts_dir / "create-new-feature.sh")
|
||||
shutil.copy(COMMON_SH, scripts_dir / "common.sh")
|
||||
(tmp_path / ".specify" / "templates").mkdir(parents=True)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def run_script(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
"""Run create-new-feature.sh with given args."""
|
||||
cmd = ["bash", "scripts/bash/create-new-feature.sh", *args]
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def source_and_call(func_call: str, env: dict | None = None) -> subprocess.CompletedProcess:
|
||||
"""Source common.sh and call a function."""
|
||||
cmd = f'source "{COMMON_SH}" && {func_call}'
|
||||
return subprocess.run(
|
||||
["bash", "-c", cmd],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, **(env or {})},
|
||||
)
|
||||
|
||||
|
||||
# ── Timestamp Branch Tests ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTimestampBranch:
|
||||
def test_timestamp_creates_branch(self, git_repo: Path):
|
||||
"""Test 1: --timestamp creates branch with YYYYMMDD-HHMMSS prefix."""
|
||||
result = run_script(git_repo, "--timestamp", "--short-name", "user-auth", "Add user auth")
|
||||
assert result.returncode == 0, result.stderr
|
||||
branch = None
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("BRANCH_NAME:"):
|
||||
branch = line.split(":", 1)[1].strip()
|
||||
assert branch is not None
|
||||
assert re.match(r"^\d{8}-\d{6}-user-auth$", branch), f"unexpected branch: {branch}"
|
||||
|
||||
def test_number_and_timestamp_warns(self, git_repo: Path):
|
||||
"""Test 3: --number + --timestamp warns and uses timestamp."""
|
||||
result = run_script(git_repo, "--timestamp", "--number", "42", "--short-name", "feat", "Feature")
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Warning" in result.stderr and "--number" in result.stderr
|
||||
|
||||
def test_json_output_keys(self, git_repo: Path):
|
||||
"""Test 4: JSON output contains expected keys."""
|
||||
import json
|
||||
result = run_script(git_repo, "--json", "--timestamp", "--short-name", "api", "API feature")
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
for key in ("BRANCH_NAME", "SPEC_FILE", "FEATURE_NUM"):
|
||||
assert key in data, f"missing {key} in JSON: {data}"
|
||||
assert re.match(r"^\d{8}-\d{6}$", data["FEATURE_NUM"])
|
||||
|
||||
def test_long_name_truncation(self, git_repo: Path):
|
||||
"""Test 5: Long branch name is truncated to <= 244 chars."""
|
||||
long_name = "a-" * 150 + "end"
|
||||
result = run_script(git_repo, "--timestamp", "--short-name", long_name, "Long feature")
|
||||
assert result.returncode == 0, result.stderr
|
||||
branch = None
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("BRANCH_NAME:"):
|
||||
branch = line.split(":", 1)[1].strip()
|
||||
assert branch is not None
|
||||
assert len(branch) <= 244
|
||||
assert re.match(r"^\d{8}-\d{6}-", branch)
|
||||
|
||||
|
||||
# ── Sequential Branch Tests ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSequentialBranch:
|
||||
def test_sequential_default_with_existing_specs(self, git_repo: Path):
|
||||
"""Test 2: Sequential default with existing specs."""
|
||||
(git_repo / "specs" / "001-first-feat").mkdir(parents=True)
|
||||
(git_repo / "specs" / "002-second-feat").mkdir(parents=True)
|
||||
result = run_script(git_repo, "--short-name", "new-feat", "New feature")
|
||||
assert result.returncode == 0, result.stderr
|
||||
branch = None
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("BRANCH_NAME:"):
|
||||
branch = line.split(":", 1)[1].strip()
|
||||
assert branch is not None
|
||||
assert re.match(r"^\d{3}-new-feat$", branch), f"unexpected branch: {branch}"
|
||||
|
||||
def test_sequential_ignores_timestamp_dirs(self, git_repo: Path):
|
||||
"""Sequential numbering skips timestamp dirs when computing next number."""
|
||||
(git_repo / "specs" / "002-first-feat").mkdir(parents=True)
|
||||
(git_repo / "specs" / "20260319-143022-ts-feat").mkdir(parents=True)
|
||||
result = run_script(git_repo, "--short-name", "next-feat", "Next feature")
|
||||
assert result.returncode == 0, result.stderr
|
||||
branch = None
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("BRANCH_NAME:"):
|
||||
branch = line.split(":", 1)[1].strip()
|
||||
assert branch == "003-next-feat", f"expected 003-next-feat, got: {branch}"
|
||||
|
||||
|
||||
# ── check_feature_branch Tests ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCheckFeatureBranch:
|
||||
def test_accepts_timestamp_branch(self):
|
||||
"""Test 6: check_feature_branch accepts timestamp branch."""
|
||||
result = source_and_call('check_feature_branch "20260319-143022-feat" "true"')
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_accepts_sequential_branch(self):
|
||||
"""Test 7: check_feature_branch accepts sequential branch."""
|
||||
result = source_and_call('check_feature_branch "004-feat" "true"')
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_rejects_main(self):
|
||||
"""Test 8: check_feature_branch rejects main."""
|
||||
result = source_and_call('check_feature_branch "main" "true"')
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_rejects_partial_timestamp(self):
|
||||
"""Test 9: check_feature_branch rejects 7-digit date."""
|
||||
result = source_and_call('check_feature_branch "2026031-143022-feat" "true"')
|
||||
assert result.returncode != 0
|
||||
|
||||
|
||||
# ── find_feature_dir_by_prefix Tests ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFindFeatureDirByPrefix:
|
||||
def test_timestamp_branch(self, tmp_path: Path):
|
||||
"""Test 10: find_feature_dir_by_prefix with timestamp branch."""
|
||||
(tmp_path / "specs" / "20260319-143022-user-auth").mkdir(parents=True)
|
||||
result = source_and_call(
|
||||
f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-user-auth"'
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-user-auth"
|
||||
|
||||
def test_cross_branch_prefix(self, tmp_path: Path):
|
||||
"""Test 11: find_feature_dir_by_prefix cross-branch (different suffix, same timestamp)."""
|
||||
(tmp_path / "specs" / "20260319-143022-original-feat").mkdir(parents=True)
|
||||
result = source_and_call(
|
||||
f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-different-name"'
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-original-feat"
|
||||
|
||||
|
||||
# ── get_current_branch Tests ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetCurrentBranch:
|
||||
def test_env_var(self):
|
||||
"""Test 12: get_current_branch returns SPECIFY_FEATURE env var."""
|
||||
result = source_and_call("get_current_branch", env={"SPECIFY_FEATURE": "my-custom-branch"})
|
||||
assert result.stdout.strip() == "my-custom-branch"
|
||||
|
||||
|
||||
# ── No-git Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNoGitTimestamp:
|
||||
def test_no_git_timestamp(self, no_git_dir: Path):
|
||||
"""Test 13: No-git repo + timestamp creates spec dir with warning."""
|
||||
result = run_script(no_git_dir, "--timestamp", "--short-name", "no-git-feat", "No git feature")
|
||||
assert result.returncode == 0, result.stderr
|
||||
spec_dirs = list((no_git_dir / "specs").iterdir()) if (no_git_dir / "specs").exists() else []
|
||||
assert len(spec_dirs) > 0, "spec dir not created"
|
||||
assert "git" in result.stderr.lower() or "warning" in result.stderr.lower()
|
||||
|
||||
|
||||
# ── E2E Flow Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestE2EFlow:
|
||||
def test_e2e_timestamp(self, git_repo: Path):
|
||||
"""Test 14: E2E timestamp flow — branch, dir, validation."""
|
||||
run_script(git_repo, "--timestamp", "--short-name", "e2e-ts", "E2E timestamp test")
|
||||
branch = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
assert re.match(r"^\d{8}-\d{6}-e2e-ts$", branch), f"branch: {branch}"
|
||||
assert (git_repo / "specs" / branch).is_dir()
|
||||
val = source_and_call(f'check_feature_branch "{branch}" "true"')
|
||||
assert val.returncode == 0
|
||||
|
||||
def test_e2e_sequential(self, git_repo: Path):
|
||||
"""Test 15: E2E sequential flow (regression guard)."""
|
||||
run_script(git_repo, "--short-name", "seq-feat", "Sequential feature")
|
||||
branch = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
assert re.match(r"^\d{3}-seq-feat$", branch), f"branch: {branch}"
|
||||
assert (git_repo / "specs" / branch).is_dir()
|
||||
val = source_and_call(f'check_feature_branch "{branch}" "true"')
|
||||
assert val.returncode == 0
|
||||
Reference in New Issue
Block a user