mirror of
https://github.com/github/spec-kit.git
synced 2026-03-21 04:43: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 |
|
| `--debug` | Flag | Enable detailed debug output for troubleshooting |
|
||||||
| `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) |
|
| `--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`) |
|
| `--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
|
### Examples
|
||||||
|
|
||||||
@@ -296,6 +297,9 @@ specify init my-project --ai claude --ai-skills
|
|||||||
# Initialize in current directory with agent skills
|
# Initialize in current directory with agent skills
|
||||||
specify init --here --ai gemini --ai-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
|
# Check system requirements
|
||||||
specify check
|
specify check
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -33,16 +33,27 @@ get_current_branch() {
|
|||||||
if [[ -d "$specs_dir" ]]; then
|
if [[ -d "$specs_dir" ]]; then
|
||||||
local latest_feature=""
|
local latest_feature=""
|
||||||
local highest=0
|
local highest=0
|
||||||
|
local latest_timestamp=""
|
||||||
|
|
||||||
for dir in "$specs_dir"/*; do
|
for dir in "$specs_dir"/*; do
|
||||||
if [[ -d "$dir" ]]; then
|
if [[ -d "$dir" ]]; then
|
||||||
local dirname=$(basename "$dir")
|
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]}
|
local number=${BASH_REMATCH[1]}
|
||||||
number=$((10#$number))
|
number=$((10#$number))
|
||||||
if [[ "$number" -gt "$highest" ]]; then
|
if [[ "$number" -gt "$highest" ]]; then
|
||||||
highest=$number
|
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
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -72,9 +83,9 @@ check_feature_branch() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
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 "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
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -90,15 +101,18 @@ find_feature_dir_by_prefix() {
|
|||||||
local branch_name="$2"
|
local branch_name="$2"
|
||||||
local specs_dir="$repo_root/specs"
|
local specs_dir="$repo_root/specs"
|
||||||
|
|
||||||
# Extract numeric prefix from branch (e.g., "004" from "004-whatever")
|
# Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches)
|
||||||
if [[ ! "$branch_name" =~ ^([0-9]{3})- ]]; then
|
local prefix=""
|
||||||
# If branch doesn't have numeric prefix, fall back to exact match
|
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"
|
echo "$specs_dir/$branch_name"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local prefix="${BASH_REMATCH[1]}"
|
|
||||||
|
|
||||||
# Search for directories in specs/ that start with this prefix
|
# Search for directories in specs/ that start with this prefix
|
||||||
local matches=()
|
local matches=()
|
||||||
if [[ -d "$specs_dir" ]]; then
|
if [[ -d "$specs_dir" ]]; then
|
||||||
@@ -119,7 +133,7 @@ find_feature_dir_by_prefix() {
|
|||||||
else
|
else
|
||||||
# Multiple matches - this shouldn't happen with proper naming convention
|
# Multiple matches - this shouldn't happen with proper naming convention
|
||||||
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
|
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
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ set -e
|
|||||||
JSON_MODE=false
|
JSON_MODE=false
|
||||||
SHORT_NAME=""
|
SHORT_NAME=""
|
||||||
BRANCH_NUMBER=""
|
BRANCH_NUMBER=""
|
||||||
|
USE_TIMESTAMP=false
|
||||||
ARGS=()
|
ARGS=()
|
||||||
i=1
|
i=1
|
||||||
while [ $i -le $# ]; do
|
while [ $i -le $# ]; do
|
||||||
arg="${!i}"
|
arg="${!i}"
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--json)
|
--json)
|
||||||
JSON_MODE=true
|
JSON_MODE=true
|
||||||
;;
|
;;
|
||||||
--short-name)
|
--short-name)
|
||||||
if [ $((i + 1)) -gt $# ]; then
|
if [ $((i + 1)) -gt $# ]; then
|
||||||
@@ -40,22 +41,27 @@ while [ $i -le $# ]; do
|
|||||||
fi
|
fi
|
||||||
BRANCH_NUMBER="$next_arg"
|
BRANCH_NUMBER="$next_arg"
|
||||||
;;
|
;;
|
||||||
--help|-h)
|
--timestamp)
|
||||||
echo "Usage: $0 [--json] [--short-name <name>] [--number N] <feature_description>"
|
USE_TIMESTAMP=true
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " --json Output in JSON format"
|
echo " --json Output in JSON format"
|
||||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
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 " --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 " --help, -h Show this help message"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Examples:"
|
echo "Examples:"
|
||||||
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
||||||
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
||||||
|
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
ARGS+=("$arg")
|
ARGS+=("$arg")
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
i=$((i + 1))
|
i=$((i + 1))
|
||||||
@@ -63,7 +69,7 @@ done
|
|||||||
|
|
||||||
FEATURE_DESCRIPTION="${ARGS[*]}"
|
FEATURE_DESCRIPTION="${ARGS[*]}"
|
||||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -96,10 +102,13 @@ get_highest_from_specs() {
|
|||||||
for dir in "$specs_dir"/*; do
|
for dir in "$specs_dir"/*; do
|
||||||
[ -d "$dir" ] || continue
|
[ -d "$dir" ] || continue
|
||||||
dirname=$(basename "$dir")
|
dirname=$(basename "$dir")
|
||||||
number=$(echo "$dirname" | grep -o '^[0-9]\+' || echo "0")
|
# Only match sequential prefixes (###-*), skip timestamp dirs
|
||||||
number=$((10#$number))
|
if echo "$dirname" | grep -q '^[0-9]\{3\}-'; then
|
||||||
if [ "$number" -gt "$highest" ]; then
|
number=$(echo "$dirname" | grep -o '^[0-9]\{3\}')
|
||||||
highest=$number
|
number=$((10#$number))
|
||||||
|
if [ "$number" -gt "$highest" ]; then
|
||||||
|
highest=$number
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
@@ -242,29 +251,42 @@ else
|
|||||||
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
|
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Determine branch number
|
# Warn if --number and --timestamp are both specified
|
||||||
if [ -z "$BRANCH_NUMBER" ]; then
|
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
|
||||||
if [ "$HAS_GIT" = true ]; then
|
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
|
||||||
# Check existing branches on remotes
|
BRANCH_NUMBER=""
|
||||||
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
|
fi
|
||||||
|
|
||||||
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
|
# Determine branch prefix
|
||||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
if [ "$USE_TIMESTAMP" = true ]; then
|
||||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
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
|
# GitHub enforces a 244-byte limit on branch names
|
||||||
# Validate and truncate if necessary
|
# Validate and truncate if necessary
|
||||||
MAX_BRANCH_LENGTH=244
|
MAX_BRANCH_LENGTH=244
|
||||||
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
|
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
|
||||||
# Calculate how much we need to trim from suffix
|
# Calculate how much we need to trim from suffix
|
||||||
# Account for: feature number (3) + hyphen (1) = 4 chars
|
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
|
||||||
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - 4))
|
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
|
||||||
|
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
|
||||||
|
|
||||||
# Truncate suffix at word boundary if possible
|
# Truncate suffix at word boundary if possible
|
||||||
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
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
|
if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
|
||||||
# Check if branch already exists
|
# Check if branch already exists
|
||||||
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
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
|
exit 1
|
||||||
else
|
else
|
||||||
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
|
>&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) {
|
if (Test-Path $specsDir) {
|
||||||
$latestFeature = ""
|
$latestFeature = ""
|
||||||
$highest = 0
|
$highest = 0
|
||||||
|
$latestTimestamp = ""
|
||||||
|
|
||||||
Get-ChildItem -Path $specsDir -Directory | ForEach-Object {
|
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]
|
$num = [int]$matches[1]
|
||||||
if ($num -gt $highest) {
|
if ($num -gt $highest) {
|
||||||
$highest = $num
|
$highest = $num
|
||||||
$latestFeature = $_.Name
|
# Only update if no timestamp branch found yet
|
||||||
|
if (-not $latestTimestamp) {
|
||||||
|
$latestFeature = $_.Name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($latestFeature) {
|
if ($latestFeature) {
|
||||||
return $latestFeature
|
return $latestFeature
|
||||||
}
|
}
|
||||||
@@ -79,9 +90,9 @@ function Test-FeatureBranch {
|
|||||||
return $true
|
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 "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 $false
|
||||||
}
|
}
|
||||||
return $true
|
return $true
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ param(
|
|||||||
[string]$ShortName,
|
[string]$ShortName,
|
||||||
[Parameter()]
|
[Parameter()]
|
||||||
[int]$Number = 0,
|
[int]$Number = 0,
|
||||||
|
[switch]$Timestamp,
|
||||||
[switch]$Help,
|
[switch]$Help,
|
||||||
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
||||||
[string[]]$FeatureDescription
|
[string[]]$FeatureDescription
|
||||||
@@ -14,23 +15,25 @@ $ErrorActionPreference = 'Stop'
|
|||||||
|
|
||||||
# Show help if requested
|
# Show help if requested
|
||||||
if ($Help) {
|
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 ""
|
||||||
Write-Host "Options:"
|
Write-Host "Options:"
|
||||||
Write-Host " -Json Output in JSON format"
|
Write-Host " -Json Output in JSON format"
|
||||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
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 " -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 " -Help Show this help message"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Examples:"
|
Write-Host "Examples:"
|
||||||
Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'"
|
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 'Implement OAuth2 integration for API'"
|
||||||
|
Write-Host " ./create-new-feature.ps1 -Timestamp -ShortName 'user-auth' 'Add user authentication'"
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if feature description provided
|
# Check if feature description provided
|
||||||
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
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
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +75,7 @@ function Get-HighestNumberFromSpecs {
|
|||||||
$highest = 0
|
$highest = 0
|
||||||
if (Test-Path $SpecsDir) {
|
if (Test-Path $SpecsDir) {
|
||||||
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
||||||
if ($_.Name -match '^(\d+)') {
|
if ($_.Name -match '^(\d{3})-') {
|
||||||
$num = [int]$matches[1]
|
$num = [int]$matches[1]
|
||||||
if ($num -gt $highest) { $highest = $num }
|
if ($num -gt $highest) { $highest = $num }
|
||||||
}
|
}
|
||||||
@@ -93,7 +96,7 @@ function Get-HighestNumberFromBranches {
|
|||||||
$cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
$cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||||
|
|
||||||
# Extract feature number if branch matches pattern ###-*
|
# Extract feature number if branch matches pattern ###-*
|
||||||
if ($cleanBranch -match '^(\d+)-') {
|
if ($cleanBranch -match '^(\d{3})-') {
|
||||||
$num = [int]$matches[1]
|
$num = [int]$matches[1]
|
||||||
if ($num -gt $highest) { $highest = $num }
|
if ($num -gt $highest) { $highest = $num }
|
||||||
}
|
}
|
||||||
@@ -216,27 +219,40 @@ if ($ShortName) {
|
|||||||
$branchSuffix = Get-BranchName -Description $featureDesc
|
$branchSuffix = Get-BranchName -Description $featureDesc
|
||||||
}
|
}
|
||||||
|
|
||||||
# Determine branch number
|
# Warn if -Number and -Timestamp are both specified
|
||||||
if ($Number -eq 0) {
|
if ($Timestamp -and $Number -ne 0) {
|
||||||
if ($hasGit) {
|
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
|
||||||
# Check existing branches on remotes
|
$Number = 0
|
||||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
|
||||||
} else {
|
|
||||||
# Fall back to local directory check
|
|
||||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$featureNum = ('{0:000}' -f $Number)
|
# Determine branch prefix
|
||||||
$branchName = "$featureNum-$branchSuffix"
|
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
|
# GitHub enforces a 244-byte limit on branch names
|
||||||
# Validate and truncate if necessary
|
# Validate and truncate if necessary
|
||||||
$maxBranchLength = 244
|
$maxBranchLength = 244
|
||||||
if ($branchName.Length -gt $maxBranchLength) {
|
if ($branchName.Length -gt $maxBranchLength) {
|
||||||
# Calculate how much we need to trim from suffix
|
# Calculate how much we need to trim from suffix
|
||||||
# Account for: feature number (3) + hyphen (1) = 4 chars
|
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
|
||||||
$maxSuffixLength = $maxBranchLength - 4
|
$prefixLength = $featureNum.Length + 1
|
||||||
|
$maxSuffixLength = $maxBranchLength - $prefixLength
|
||||||
|
|
||||||
# Truncate suffix
|
# Truncate suffix
|
||||||
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
|
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
|
||||||
@@ -266,7 +282,11 @@ if ($hasGit) {
|
|||||||
# Check if branch already exists
|
# Check if branch already exists
|
||||||
$existingBranch = git branch --list $branchName 2>$null
|
$existingBranch = git branch --list $branchName 2>$null
|
||||||
if ($existingBranch) {
|
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
|
exit 1
|
||||||
} else {
|
} else {
|
||||||
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
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)"),
|
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)"),
|
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)"),
|
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.
|
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")
|
console.print("[yellow]Usage:[/yellow] specify init <project> --ai <agent> --ai-skills")
|
||||||
raise typer.Exit(1)
|
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:
|
if here:
|
||||||
project_name = Path.cwd().name
|
project_name = Path.cwd().name
|
||||||
project_path = Path.cwd()
|
project_path = Path.cwd()
|
||||||
@@ -1781,6 +1787,7 @@ def init(
|
|||||||
"ai": selected_ai,
|
"ai": selected_ai,
|
||||||
"ai_skills": ai_skills,
|
"ai_skills": ai_skills,
|
||||||
"ai_commands_dir": ai_commands_dir,
|
"ai_commands_dir": ai_commands_dir,
|
||||||
|
"branch_numbering": branch_numbering or "sequential",
|
||||||
"here": here,
|
"here": here,
|
||||||
"preset": preset,
|
"preset": preset,
|
||||||
"script": selected_script,
|
"script": selected_script,
|
||||||
|
|||||||
@@ -73,10 +73,16 @@ Given that feature description, do this:
|
|||||||
- "Create a dashboard for analytics" → "analytics-dashboard"
|
- "Create a dashboard for analytics" → "analytics-dashboard"
|
||||||
- "Fix payment processing timeout bug" → "fix-payment-timeout"
|
- "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 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 example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"`
|
||||||
|
- PowerShell (timestamp): `{SCRIPT} -Json -Timestamp -ShortName "user-auth" "Add user authentication"`
|
||||||
|
|
||||||
**IMPORTANT**:
|
**IMPORTANT**:
|
||||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
- 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