mirror of
https://github.com/github/spec-kit.git
synced 2026-07-07 14:39:00 +08:00
Support namespaced git feature branch templates (#3293)
* test: cover namespaced git branch templates
Assisted-by: Codex (model: GPT-5, autonomous)
* feat: support namespaced git branch templates
Assisted-by: Codex (model: GPT-5, autonomous)
* test: cover git branch template edge cases
Assisted-by: Codex (model: GPT-5, autonomous)
* fix: harden git branch template parsing
Assisted-by: Codex (model: GPT-5, autonomous)
* fix: address git branch template review feedback
Address Copilot review feedback for branch_prefix help text, namespaced GIT_BRANCH_NAME fallback behavior, final-segment validation docs, and Bash UTF-8 byte reporting.
Assisted-by: Codex (model: GPT-5, autonomous)
* fix: reject slug-scoped branch templates
Reject branch templates that place {slug} before {number}, because that makes namespace scanning depend on the generated feature slug and can reset numbering per feature name.
Assisted-by: Codex (model: GPT-5, autonomous)
* fix: ignore malformed timestamp refs when numbering
Align branch-number scanning with feature-branch validation so malformed timestamp-looking refs do not inflate sequential numbering. Also updates the stale git-common comment called out in review.
Assisted-by: Codex (model: GPT-5, autonomous)
This commit is contained in:
@@ -75,6 +75,10 @@ while [ $i -le $# ]; do
|
||||
echo "Environment variables:"
|
||||
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
||||
echo ""
|
||||
echo "Configuration:"
|
||||
echo " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}"
|
||||
echo " branch_prefix Optional shorthand namespace expanded before {number}-{slug}"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
||||
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
||||
@@ -127,16 +131,28 @@ get_highest_from_specs() {
|
||||
|
||||
# Function to get highest number from git branches
|
||||
get_highest_from_branches() {
|
||||
git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number
|
||||
local scope_prefix="${1:-}"
|
||||
git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number "$scope_prefix"
|
||||
}
|
||||
|
||||
# Extract the highest sequential feature number from a list of ref names (one per line).
|
||||
_extract_highest_number() {
|
||||
local scope_prefix="${1:-}"
|
||||
local highest=0
|
||||
while IFS= read -r name; do
|
||||
[ -z "$name" ] && continue
|
||||
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
|
||||
if [ -n "$scope_prefix" ]; then
|
||||
case "$name" in
|
||||
"$scope_prefix"*) name="${name#"$scope_prefix"}" ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
fi
|
||||
name="${name##*/}"
|
||||
if echo "$name" | grep -Eq '^[0-9]{3,}-' \
|
||||
&& ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-' \
|
||||
&& ! echo "$name" | grep -Eq '^[0-9]{7}-[0-9]{6}-' \
|
||||
&& ! echo "$name" | grep -Eq '^[0-9]{7,8}-[0-9]{6}$'; then
|
||||
number=$(echo "$name" | grep -Eo '^[0-9]{3,}-' | sed -E 's/-$//' || echo "0")
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
@@ -148,11 +164,12 @@ _extract_highest_number() {
|
||||
|
||||
# Function to get highest number from remote branches without fetching (side-effect-free)
|
||||
get_highest_from_remote_refs() {
|
||||
local scope_prefix="${1:-}"
|
||||
local highest=0
|
||||
|
||||
for remote in $(git remote 2>/dev/null); do
|
||||
local remote_highest
|
||||
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
|
||||
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number "$scope_prefix")
|
||||
if [ "$remote_highest" -gt "$highest" ]; then
|
||||
highest=$remote_highest
|
||||
fi
|
||||
@@ -165,16 +182,17 @@ get_highest_from_remote_refs() {
|
||||
check_existing_branches() {
|
||||
local specs_dir="$1"
|
||||
local skip_fetch="${2:-false}"
|
||||
local scope_prefix="${3:-}"
|
||||
|
||||
if [ "$skip_fetch" = true ]; then
|
||||
local highest_remote=$(get_highest_from_remote_refs)
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
local highest_remote=$(get_highest_from_remote_refs "$scope_prefix")
|
||||
local highest_branch=$(get_highest_from_branches "$scope_prefix")
|
||||
if [ "$highest_remote" -gt "$highest_branch" ]; then
|
||||
highest_branch=$highest_remote
|
||||
fi
|
||||
else
|
||||
git fetch --all --prune >/dev/null 2>&1 || true
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
local highest_branch=$(get_highest_from_branches "$scope_prefix")
|
||||
fi
|
||||
|
||||
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
||||
@@ -273,6 +291,152 @@ fi
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
SPECS_DIR="$REPO_ROOT/specs"
|
||||
CONFIG_FILE="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
||||
|
||||
read_git_config_value() {
|
||||
local key="$1"
|
||||
[ -f "$CONFIG_FILE" ] || return 0
|
||||
grep -E "^[[:space:]]*${key}:" "$CONFIG_FILE" 2>/dev/null \
|
||||
| head -n 1 \
|
||||
| sed -E "s/^[[:space:]]*${key}:[[:space:]]*//" \
|
||||
| sed -E 's/[[:space:]]+#.*$//' \
|
||||
| sed -E 's/^[[:space:]]+|[[:space:]]+$//g' \
|
||||
| sed -E 's/^"//; s/"$//' \
|
||||
| sed -E "s/^'//; s/'$//"
|
||||
}
|
||||
|
||||
branch_token() {
|
||||
local value="$1"
|
||||
local fallback="$2"
|
||||
local cleaned
|
||||
cleaned=$(clean_branch_name "$value")
|
||||
if [ -n "$cleaned" ]; then
|
||||
printf '%s\n' "$cleaned"
|
||||
else
|
||||
printf '%s\n' "$fallback"
|
||||
fi
|
||||
}
|
||||
|
||||
get_author_token() {
|
||||
local author=""
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
author=$(git config user.name 2>/dev/null || true)
|
||||
if [ -z "$author" ]; then
|
||||
author=$(git config user.email 2>/dev/null | sed 's/@.*$//' || true)
|
||||
fi
|
||||
fi
|
||||
if [ -z "$author" ]; then
|
||||
author="${USER:-unknown}"
|
||||
fi
|
||||
branch_token "$author" "unknown"
|
||||
}
|
||||
|
||||
get_app_token() {
|
||||
branch_token "$(basename "$REPO_ROOT")" "app"
|
||||
}
|
||||
|
||||
resolve_branch_template() {
|
||||
local template
|
||||
local prefix
|
||||
template=$(read_git_config_value "branch_template")
|
||||
if [ -n "$template" ]; then
|
||||
printf '%s\n' "$template"
|
||||
return
|
||||
fi
|
||||
|
||||
prefix=$(read_git_config_value "branch_prefix")
|
||||
if [ -z "$prefix" ]; then
|
||||
printf '%s\n' ""
|
||||
return
|
||||
fi
|
||||
case "$prefix" in
|
||||
*/) printf '%s%s\n' "$prefix" "{number}-{slug}" ;;
|
||||
*) printf '%s/%s\n' "$prefix" "{number}-{slug}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
render_branch_template() {
|
||||
local template="$1"
|
||||
local feature_num="$2"
|
||||
local branch_suffix="$3"
|
||||
local rendered="$template"
|
||||
rendered=${rendered//\{author\}/$AUTHOR_TOKEN}
|
||||
rendered=${rendered//\{app\}/$APP_TOKEN}
|
||||
rendered=${rendered//\{number\}/$feature_num}
|
||||
rendered=${rendered//\{slug\}/$branch_suffix}
|
||||
printf '%s\n' "$rendered"
|
||||
}
|
||||
|
||||
validate_branch_template() {
|
||||
local template="$1"
|
||||
[ -n "$template" ] || return 0
|
||||
local feature_segment
|
||||
feature_segment="${template##*/}"
|
||||
case "$template" in
|
||||
*"{number}"*) ;;
|
||||
*)
|
||||
>&2 echo "Error: branch_template must include the {number} token so generated branches remain valid feature branches."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$template" in
|
||||
*"{slug}"*"{number}"*)
|
||||
>&2 echo "Error: branch_template must not place {slug} before {number}; use {slug} only in the final feature segment."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$feature_segment" in
|
||||
"{number}-"*) ;;
|
||||
*)
|
||||
>&2 echo "Error: branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_branch_name() {
|
||||
local feature_num="$1"
|
||||
local branch_suffix="$2"
|
||||
if [ -n "$BRANCH_TEMPLATE" ]; then
|
||||
render_branch_template "$BRANCH_TEMPLATE" "$feature_num" "$branch_suffix"
|
||||
else
|
||||
printf '%s-%s\n' "$feature_num" "$branch_suffix"
|
||||
fi
|
||||
}
|
||||
|
||||
branch_scope_prefix() {
|
||||
local template="$1"
|
||||
local prefix="$template"
|
||||
[ -n "$prefix" ] || return 0
|
||||
case "$prefix" in
|
||||
*"{number}"*) prefix="${prefix%%\{number\}*}" ;;
|
||||
*"{slug}"*) prefix="${prefix%%\{slug\}*}" ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
render_branch_template "$prefix" "" "$BRANCH_SUFFIX"
|
||||
}
|
||||
|
||||
extract_feature_num_from_branch() {
|
||||
local branch_name="$1"
|
||||
local feature_segment="${branch_name##*/}"
|
||||
local match
|
||||
match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]{8}-[0-9]{6}-' | head -n 1 || true)
|
||||
if [ -n "$match" ]; then
|
||||
printf '%s\n' "$match" | sed -E 's/-$//'
|
||||
return
|
||||
fi
|
||||
match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]+-' | head -n 1 || true)
|
||||
if [ -n "$match" ]; then
|
||||
printf '%s\n' "$match" | sed -E 's/-$//'
|
||||
return
|
||||
fi
|
||||
printf '%s\n' "$branch_name"
|
||||
}
|
||||
|
||||
AUTHOR_TOKEN=$(get_author_token)
|
||||
APP_TOKEN=$(get_app_token)
|
||||
BRANCH_TEMPLATE=$(resolve_branch_template)
|
||||
validate_branch_template "$BRANCH_TEMPLATE"
|
||||
|
||||
# Function to generate branch name with stop word filtering
|
||||
generate_branch_name() {
|
||||
@@ -318,18 +482,8 @@ generate_branch_name() {
|
||||
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
|
||||
if [ -n "${GIT_BRANCH_NAME:-}" ]; then
|
||||
BRANCH_NAME="$GIT_BRANCH_NAME"
|
||||
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
|
||||
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern
|
||||
if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}')
|
||||
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
|
||||
elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then
|
||||
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+')
|
||||
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
|
||||
else
|
||||
FEATURE_NUM="$BRANCH_NAME"
|
||||
BRANCH_SUFFIX="$BRANCH_NAME"
|
||||
fi
|
||||
FEATURE_NUM=$(extract_feature_num_from_branch "$BRANCH_NAME")
|
||||
BRANCH_SUFFIX="$BRANCH_NAME"
|
||||
else
|
||||
# Generate branch name
|
||||
if [ -n "$SHORT_NAME" ]; then
|
||||
@@ -347,16 +501,17 @@ else
|
||||
# Determine branch prefix
|
||||
if [ "$USE_TIMESTAMP" = true ]; then
|
||||
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
|
||||
else
|
||||
BRANCH_SCOPE_PREFIX=$(branch_scope_prefix "$BRANCH_TEMPLATE")
|
||||
if [ -z "$BRANCH_NUMBER" ]; then
|
||||
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true "$BRANCH_SCOPE_PREFIX")
|
||||
elif [ "$DRY_RUN" = true ]; then
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
elif [ "$HAS_GIT" = true ]; then
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" false "$BRANCH_SCOPE_PREFIX")
|
||||
else
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
@@ -364,7 +519,7 @@ else
|
||||
fi
|
||||
|
||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -376,18 +531,23 @@ if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH
|
||||
>&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
|
||||
exit 1
|
||||
elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
|
||||
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
|
||||
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
|
||||
|
||||
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
||||
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
|
||||
|
||||
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
||||
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
||||
TRUNCATED_SUFFIX="$BRANCH_SUFFIX"
|
||||
while [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ] && [ -n "$TRUNCATED_SUFFIX" ]; do
|
||||
TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%?}"
|
||||
TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%-}"
|
||||
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$TRUNCATED_SUFFIX")
|
||||
done
|
||||
if [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ]; then
|
||||
>&2 echo "Error: Branch template prefix exceeds GitHub's 244-byte branch name limit."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
>&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)"
|
||||
ORIGINAL_BRANCH_BYTE_LEN=$(_byte_length "$ORIGINAL_BRANCH_NAME")
|
||||
TRUNCATED_BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
|
||||
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${ORIGINAL_BRANCH_BYTE_LEN} bytes)"
|
||||
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${TRUNCATED_BRANCH_BYTE_LEN} bytes)"
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
|
||||
@@ -23,8 +23,9 @@ spec_kit_effective_branch_name() {
|
||||
}
|
||||
|
||||
# Validate that a branch name matches the expected feature branch pattern.
|
||||
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats.
|
||||
# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization.
|
||||
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats,
|
||||
# either at the start of the branch or after path-style namespace prefixes.
|
||||
# Logic aligned with the git extension's PowerShell Test-FeatureBranch twin.
|
||||
check_feature_branch() {
|
||||
local raw="$1"
|
||||
local has_git_repo="$2"
|
||||
@@ -37,16 +38,17 @@ check_feature_branch() {
|
||||
|
||||
local branch
|
||||
branch=$(spec_kit_effective_branch_name "$raw")
|
||||
local feature_segment="${branch##*/}"
|
||||
|
||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||
local is_sequential=false
|
||||
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
|
||||
if [[ "$feature_segment" =~ ^[0-9]{3,}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
|
||||
is_sequential=true
|
||||
fi
|
||||
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||
if [[ "$is_sequential" != "true" ]] && [[ ! "$feature_segment" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
|
||||
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
|
||||
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or <prefix>/001-feature-name" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ if ($Help) {
|
||||
Write-Host "Environment variables:"
|
||||
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
||||
Write-Host ""
|
||||
Write-Host "Configuration:"
|
||||
Write-Host " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}"
|
||||
Write-Host " branch_prefix Optional shorthand namespace expanded before {number}-{slug}"
|
||||
Write-Host ""
|
||||
exit 0
|
||||
}
|
||||
|
||||
@@ -67,11 +71,23 @@ function Get-HighestNumberFromSpecs {
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromNames {
|
||||
param([string[]]$Names)
|
||||
param(
|
||||
[string[]]$Names,
|
||||
[string]$ScopePrefix = ''
|
||||
)
|
||||
|
||||
[long]$highest = 0
|
||||
foreach ($name in $Names) {
|
||||
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
|
||||
if ($ScopePrefix -and -not $name.StartsWith($ScopePrefix, [System.StringComparison]::Ordinal)) {
|
||||
continue
|
||||
}
|
||||
if ($ScopePrefix) {
|
||||
$name = $name.Substring($ScopePrefix.Length)
|
||||
}
|
||||
$name = ($name -split '/')[-1]
|
||||
$hasTimestampPrefix = $name -match '^\d{8}-\d{6}-'
|
||||
$hasMalformedTimestamp = ($name -match '^\d{7}-\d{6}-') -or ($name -match '^(?:\d{7}|\d{8})-\d{6}$')
|
||||
if ($name -match '^(\d{3,})-' -and -not $hasTimestampPrefix -and -not $hasMalformedTimestamp) {
|
||||
[long]$num = 0
|
||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||
$highest = $num
|
||||
@@ -82,7 +98,7 @@ function Get-HighestNumberFromNames {
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromBranches {
|
||||
param()
|
||||
param([string]$ScopePrefix = '')
|
||||
|
||||
try {
|
||||
$branches = git branch -a 2>$null
|
||||
@@ -90,7 +106,7 @@ function Get-HighestNumberFromBranches {
|
||||
$cleanNames = $branches | ForEach-Object {
|
||||
$_.Trim() -replace '^[+*]?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||
}
|
||||
return Get-HighestNumberFromNames -Names $cleanNames
|
||||
return Get-HighestNumberFromNames -Names $cleanNames -ScopePrefix $ScopePrefix
|
||||
}
|
||||
} catch {
|
||||
Write-Verbose "Could not check Git branches: $_"
|
||||
@@ -99,6 +115,8 @@ function Get-HighestNumberFromBranches {
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromRemoteRefs {
|
||||
param([string]$ScopePrefix = '')
|
||||
|
||||
[long]$highest = 0
|
||||
try {
|
||||
$remotes = git remote 2>$null
|
||||
@@ -111,7 +129,7 @@ function Get-HighestNumberFromRemoteRefs {
|
||||
$refNames = $refs | ForEach-Object {
|
||||
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
|
||||
} | Where-Object { $_ }
|
||||
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
|
||||
$remoteHighest = Get-HighestNumberFromNames -Names $refNames -ScopePrefix $ScopePrefix
|
||||
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
|
||||
}
|
||||
}
|
||||
@@ -125,18 +143,19 @@ function Get-HighestNumberFromRemoteRefs {
|
||||
function Get-NextBranchNumber {
|
||||
param(
|
||||
[string]$SpecsDir,
|
||||
[switch]$SkipFetch
|
||||
[switch]$SkipFetch,
|
||||
[string]$ScopePrefix = ''
|
||||
)
|
||||
|
||||
if ($SkipFetch) {
|
||||
$highestBranch = Get-HighestNumberFromBranches
|
||||
$highestRemote = Get-HighestNumberFromRemoteRefs
|
||||
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
|
||||
$highestRemote = Get-HighestNumberFromRemoteRefs -ScopePrefix $ScopePrefix
|
||||
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
|
||||
} else {
|
||||
try {
|
||||
git fetch --all --prune 2>$null | Out-Null
|
||||
} catch { }
|
||||
$highestBranch = Get-HighestNumberFromBranches
|
||||
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
|
||||
}
|
||||
|
||||
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
||||
@@ -232,6 +251,145 @@ if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
|
||||
Set-Location $repoRoot
|
||||
|
||||
$specsDir = Join-Path $repoRoot 'specs'
|
||||
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
|
||||
|
||||
function Read-GitConfigValue {
|
||||
param([string]$Key)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $configFile -PathType Leaf)) { return '' }
|
||||
$escapedKey = [regex]::Escape($Key)
|
||||
foreach ($line in Get-Content -LiteralPath $configFile) {
|
||||
if ($line -match "^\s*$escapedKey\s*:\s*(.*)$") {
|
||||
$val = ($matches[1] -replace '\s+#.*$', '').Trim()
|
||||
$val = $val -replace '^["'']', '' -replace '["'']$', ''
|
||||
return $val
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function ConvertTo-BranchToken {
|
||||
param(
|
||||
[string]$Value,
|
||||
[string]$Fallback
|
||||
)
|
||||
|
||||
$cleaned = ConvertTo-CleanBranchName -Name $Value
|
||||
if ($cleaned) { return $cleaned }
|
||||
return $Fallback
|
||||
}
|
||||
|
||||
function Get-GitAuthorToken {
|
||||
$author = ''
|
||||
if (Get-Command git -ErrorAction SilentlyContinue) {
|
||||
try { $author = (git config user.name 2>$null | Out-String).Trim() } catch {}
|
||||
if (-not $author) {
|
||||
try {
|
||||
$email = (git config user.email 2>$null | Out-String).Trim()
|
||||
if ($email) { $author = ($email -split '@')[0] }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (-not $author) { $author = if ($env:USER) { $env:USER } elseif ($env:USERNAME) { $env:USERNAME } else { 'unknown' } }
|
||||
return ConvertTo-BranchToken -Value $author -Fallback 'unknown'
|
||||
}
|
||||
|
||||
function Get-AppToken {
|
||||
return ConvertTo-BranchToken -Value (Split-Path $repoRoot -Leaf) -Fallback 'app'
|
||||
}
|
||||
|
||||
function Resolve-BranchTemplate {
|
||||
$template = Read-GitConfigValue -Key 'branch_template'
|
||||
if ($template) { return $template }
|
||||
|
||||
$prefix = Read-GitConfigValue -Key 'branch_prefix'
|
||||
if (-not $prefix) { return '' }
|
||||
if ($prefix.EndsWith('/')) { return "${prefix}{number}-{slug}" }
|
||||
return "$prefix/{number}-{slug}"
|
||||
}
|
||||
|
||||
function Expand-BranchTemplate {
|
||||
param(
|
||||
[string]$Template,
|
||||
[string]$FeatureNum,
|
||||
[string]$BranchSuffix
|
||||
)
|
||||
|
||||
$rendered = $Template.Replace('{author}', $authorToken)
|
||||
$rendered = $rendered.Replace('{app}', $appToken)
|
||||
$rendered = $rendered.Replace('{number}', $FeatureNum)
|
||||
$rendered = $rendered.Replace('{slug}', $BranchSuffix)
|
||||
return $rendered
|
||||
}
|
||||
|
||||
function Assert-BranchTemplateValid {
|
||||
param([string]$Template)
|
||||
|
||||
if ($Template -and -not $Template.Contains('{number}')) {
|
||||
throw "branch_template must include the {number} token so generated branches remain valid feature branches."
|
||||
}
|
||||
if ($Template) {
|
||||
$numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
|
||||
$slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
|
||||
if ($slugIndex -ge 0 -and $slugIndex -lt $numberIndex) {
|
||||
throw "branch_template must not place {slug} before {number}; use {slug} only in the final feature segment."
|
||||
}
|
||||
$featureSegment = ($Template -split '/')[-1]
|
||||
if (-not $featureSegment.StartsWith('{number}-', [System.StringComparison]::Ordinal)) {
|
||||
throw "branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function New-BranchName {
|
||||
param(
|
||||
[string]$FeatureNum,
|
||||
[string]$BranchSuffix
|
||||
)
|
||||
|
||||
if ($branchTemplate) {
|
||||
return Expand-BranchTemplate -Template $branchTemplate -FeatureNum $FeatureNum -BranchSuffix $BranchSuffix
|
||||
}
|
||||
return "$FeatureNum-$BranchSuffix"
|
||||
}
|
||||
|
||||
function Get-BranchScopePrefix {
|
||||
param(
|
||||
[string]$Template,
|
||||
[string]$BranchSuffix
|
||||
)
|
||||
|
||||
if (-not $Template) { return '' }
|
||||
$numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
|
||||
$slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
|
||||
$indexes = @($numberIndex, $slugIndex) | Where-Object { $_ -ge 0 } | Sort-Object
|
||||
if (-not $indexes) { return '' }
|
||||
$prefix = $Template.Substring(0, $indexes[0])
|
||||
return Expand-BranchTemplate -Template $prefix -FeatureNum '' -BranchSuffix $BranchSuffix
|
||||
}
|
||||
|
||||
function Get-FeatureNumberFromBranchName {
|
||||
param([string]$BranchName)
|
||||
|
||||
$featureSegment = ($BranchName -split '/')[-1]
|
||||
if ($featureSegment -match '^(\d{8}-\d{6})-') {
|
||||
return $matches[1]
|
||||
}
|
||||
if ($featureSegment -match '^(\d+)-') {
|
||||
return $matches[1]
|
||||
}
|
||||
return $BranchName
|
||||
}
|
||||
|
||||
function Get-Utf8ByteCount {
|
||||
param([string]$Value)
|
||||
return [System.Text.Encoding]::UTF8.GetByteCount($Value)
|
||||
}
|
||||
|
||||
$authorToken = Get-GitAuthorToken
|
||||
$appToken = Get-AppToken
|
||||
$branchTemplate = Resolve-BranchTemplate
|
||||
Assert-BranchTemplateValid -Template $branchTemplate
|
||||
|
||||
function Get-BranchName {
|
||||
param([string]$Description)
|
||||
@@ -276,19 +434,11 @@ function Get-BranchName {
|
||||
if ($env:GIT_BRANCH_NAME) {
|
||||
$branchName = $env:GIT_BRANCH_NAME
|
||||
# Check 244-byte limit (UTF-8) for override names
|
||||
$branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName)
|
||||
$branchNameUtf8ByteCount = Get-Utf8ByteCount -Value $branchName
|
||||
if ($branchNameUtf8ByteCount -gt 244) {
|
||||
throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name."
|
||||
}
|
||||
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
|
||||
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern
|
||||
if ($branchName -match '^(\d{8}-\d{6})-') {
|
||||
$featureNum = $matches[1]
|
||||
} elseif ($branchName -match '^(\d+)-') {
|
||||
$featureNum = $matches[1]
|
||||
} else {
|
||||
$featureNum = $branchName
|
||||
}
|
||||
$featureNum = Get-FeatureNumberFromBranchName -BranchName $branchName
|
||||
} else {
|
||||
if ($ShortName) {
|
||||
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
||||
@@ -303,39 +453,41 @@ if ($env:GIT_BRANCH_NAME) {
|
||||
|
||||
if ($Timestamp) {
|
||||
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
} else {
|
||||
$branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix
|
||||
if ($Number -eq 0) {
|
||||
if ($DryRun -and $hasGit) {
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix
|
||||
} elseif ($DryRun) {
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
} elseif ($hasGit) {
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -ScopePrefix $branchScopePrefix
|
||||
} else {
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
}
|
||||
}
|
||||
|
||||
$featureNum = ('{0:000}' -f $Number)
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
}
|
||||
}
|
||||
|
||||
$maxBranchLength = 244
|
||||
if ($branchName.Length -gt $maxBranchLength) {
|
||||
$prefixLength = $featureNum.Length + 1
|
||||
$maxSuffixLength = $maxBranchLength - $prefixLength
|
||||
|
||||
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
|
||||
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
||||
|
||||
if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
|
||||
$originalBranchName = $branchName
|
||||
$branchName = "$featureNum-$truncatedSuffix"
|
||||
$truncatedSuffix = $branchSuffix
|
||||
while ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength -and $truncatedSuffix.Length -gt 0) {
|
||||
$truncatedSuffix = $truncatedSuffix.Substring(0, $truncatedSuffix.Length - 1) -replace '-$', ''
|
||||
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $truncatedSuffix
|
||||
}
|
||||
if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
|
||||
throw "Branch template prefix exceeds GitHub's 244-byte branch name limit."
|
||||
}
|
||||
|
||||
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)"
|
||||
Write-Warning "[specify] Original: $originalBranchName ($(Get-Utf8ByteCount -Value $originalBranchName) bytes)"
|
||||
Write-Warning "[specify] Truncated to: $branchName ($(Get-Utf8ByteCount -Value $branchName) bytes)"
|
||||
}
|
||||
|
||||
if (-not $DryRun) {
|
||||
|
||||
@@ -37,14 +37,15 @@ function Test-FeatureBranch {
|
||||
|
||||
$raw = $Branch
|
||||
$Branch = Get-SpecKitEffectiveBranchName $raw
|
||||
$featureSegment = ($Branch -split '/')[-1]
|
||||
|
||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
|
||||
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
||||
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
|
||||
# Accept sequential prefix (3+ digits), at the start or after namespace
|
||||
# segments, but exclude malformed timestamps.
|
||||
$hasMalformedTimestamp = ($featureSegment -match '^[0-9]{7}-[0-9]{6}-') -or ($featureSegment -match '^(?:\d{7}|\d{8})-\d{6}$')
|
||||
$isSequential = ($featureSegment -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
||||
if (-not $isSequential -and $featureSegment -notmatch '^\d{8}-\d{6}-') {
|
||||
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
|
||||
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
|
||||
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or <prefix>/001-feature-name")
|
||||
return $false
|
||||
}
|
||||
return $true
|
||||
|
||||
Reference in New Issue
Block a user