Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 63 additions & 17 deletions scripts/bash/create-new-feature.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ ALLOW_EXISTING=false
SHORT_NAME=""
BRANCH_NUMBER=""
USE_TIMESTAMP=false
NUMBER_EXPLICIT=false
ARGS=()
i=1
while [ $i -le $# ]; do
Expand Down Expand Up @@ -48,6 +49,7 @@ while [ $i -le $# ]; do
exit 1
fi
Comment thread
mnriem marked this conversation as resolved.
BRANCH_NUMBER="$next_arg"
NUMBER_EXPLICIT=true
;;
--timestamp)
USE_TIMESTAMP=true
Expand All @@ -60,7 +62,7 @@ while [ $i -le $# ]; do
echo " --dry-run Compute feature name and paths without creating directories or files"
echo " --allow-existing-branch Reuse an existing feature directory if it already exists"
echo " --short-name <name> Provide a custom short name (2-4 words) for the feature"
echo " --number N Specify branch number manually (overrides auto-detection)"
echo " --number N Prefer a feature number (auto-corrected if its specs prefix exists)"
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
echo " --help, -h Show this help message"
echo ""
Expand Down Expand Up @@ -91,6 +93,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
fi

MAX_FEATURE_NUMBER=9223372036854775807
MAX_BRANCH_LENGTH=244

is_feature_number_in_range() {
local value="$1"
Expand Down Expand Up @@ -128,12 +131,40 @@ get_highest_from_specs() {
echo "$highest"
}

# Return success when a spec directory owns the given numeric prefix.
spec_prefix_exists() {
local specs_dir="$1"
local feature_num="$2"

for spec_path in "$specs_dir/${feature_num}-"*; do
[ -d "$spec_path" ] && return 0
done
return 1
}

# Function to clean and format a branch name
clean_branch_name() {
local name="$1"
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
}

# Fit a feature prefix and suffix within GitHub's branch-name limit.
fit_branch_name() {
local feature_num="$1"
local branch_suffix="$2"
local branch_name="${feature_num}-${branch_suffix}"

if [ ${#branch_name} -gt $MAX_BRANCH_LENGTH ]; then
local prefix_length=$(( ${#feature_num} + 1 ))
local max_suffix_length=$((MAX_BRANCH_LENGTH - prefix_length))
local truncated_suffix
truncated_suffix=$(printf '%s' "$branch_suffix" | cut -c "1-$max_suffix_length" | sed 's/-$//')
branch_name="${feature_num}-${truncated_suffix}"
fi

printf '%s' "$branch_name"
}

# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote
# so the persistence hints match the Python variant exactly (printf %q output
# differs between bash versions and from shlex.quote for spaces/metachars).
Expand Down Expand Up @@ -253,26 +284,41 @@ else

# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
fi

# GitHub enforces a 244-byte limit on branch names
# Validate and truncate if necessary
MAX_BRANCH_LENGTH=244
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
# Calculate how much we need to trim from suffix
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
# Treat an explicit number as a preference when its prefix is already used
# by a feature directory. Auto-detected numbers are already conflict-free.
if [ "$NUMBER_EXPLICIT" = true ]; then
SPEC_CONFLICT=false
REQUESTED_BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
REQUESTED_DIR="$SPECS_DIR/$REQUESTED_BRANCH_NAME"
if [ "$ALLOW_EXISTING" != true ] || [ ! -d "$REQUESTED_DIR" ]; then
spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" && SPEC_CONFLICT=true
fi

# Truncate suffix at word boundary if possible
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
# Remove trailing hyphen if truncation created one
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
if [ "$SPEC_CONFLICT" = true ]; then
REQUESTED_NUM="$FEATURE_NUM"
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$HIGHEST
while true; do
if [ "$BRANCH_NUMBER" -eq "$MAX_FEATURE_NUMBER" ]; then
echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2
exit 1
fi
BRANCH_NUMBER=$((BRANCH_NUMBER + 1))
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" || break
done
>&2 echo "[specify] Warning: --number $REQUESTED_NUM conflicts with an existing spec directory; using $FEATURE_NUM instead"
fi
fi

ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
fi

# GitHub enforces a 244-byte limit on branch names
# Validate and truncate if necessary
ORIGINAL_BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
if [ "$BRANCH_NAME" != "$ORIGINAL_BRANCH_NAME" ]; then
>&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)"
Expand Down
82 changes: 65 additions & 17 deletions scripts/powershell/create-new-feature.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ param(
[string[]]$FeatureDescription
)
$ErrorActionPreference = 'Stop'
$maxBranchLength = 244

# Show help if requested
if ($Help) {
Expand All @@ -24,7 +25,7 @@ if ($Help) {
Write-Host " -DryRun Compute feature name and paths without creating directories or files"
Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the feature"
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
Write-Host " -Number N Prefer a feature number (auto-corrected if its specs prefix exists)"
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
Write-Host " -Help Show this help message"
Write-Host ""
Expand Down Expand Up @@ -67,11 +68,44 @@ function Get-HighestNumberFromSpecs {
return $highest
}

function Test-SpecPrefixInUse {
param(
[string]$SpecsDir,
[string]$FeatureNum
)

if (-not (Test-Path -LiteralPath $SpecsDir -PathType Container)) {
return $false
}

return $null -ne (Get-ChildItem -LiteralPath $SpecsDir -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like "$FeatureNum-*" } |
Select-Object -First 1)
}

function ConvertTo-CleanBranchName {
param([string]$Name)

return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
}

function Get-FittedBranchName {
param(
[string]$FeatureNum,
[string]$BranchSuffix
)

$fittedName = "$FeatureNum-$BranchSuffix"
if ($fittedName.Length -gt $maxBranchLength) {
$prefixLength = $FeatureNum.Length + 1
$maxSuffixLength = $maxBranchLength - $prefixLength
$truncatedSuffix = $BranchSuffix.Substring(0, [Math]::Min($BranchSuffix.Length, $maxSuffixLength))
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
$fittedName = "$FeatureNum-$truncatedSuffix"
}

return $fittedName
}
# Load common functions (includes Get-RepoRoot and Resolve-Template)
. "$PSScriptRoot/common.ps1"

Expand Down Expand Up @@ -176,26 +210,40 @@ if ($Timestamp) {
}

$featureNum = ('{0:000}' -f $resolvedNumber)
$branchName = "$featureNum-$branchSuffix"
}

# GitHub enforces a 244-byte limit on branch names
# Validate and truncate if necessary
$maxBranchLength = 244
if ($branchName.Length -gt $maxBranchLength) {
# Calculate how much we need to trim from suffix
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
$prefixLength = $featureNum.Length + 1
$maxSuffixLength = $maxBranchLength - $prefixLength
# Treat an explicit number as a preference when its prefix is already used
# by a feature directory. Auto-detected numbers are already conflict-free.
$specConflict = $false
if ($hasNumber -and (Test-Path -LiteralPath $specsDir -PathType Container)) {
$requestedBranchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
$requestedDir = Join-Path $specsDir $requestedBranchName
if (-not $AllowExistingBranch -or -not (Test-Path -LiteralPath $requestedDir -PathType Container)) {
$specConflict = Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum
}
}

# Truncate suffix
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
# Remove trailing hyphen if truncation created one
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
if ($specConflict) {
$requestedNum = $featureNum
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
$resolvedNumber = $highestNumber
do {
if ($resolvedNumber -eq [long]::MaxValue) {
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
exit 1
}
$resolvedNumber++
$featureNum = ('{0:000}' -f $resolvedNumber)
} while (Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum)
[Console]::Error.WriteLine("[specify] Warning: -Number $requestedNum conflicts with an existing spec directory; using $featureNum instead")
}

$originalBranchName = $branchName
$branchName = "$featureNum-$truncatedSuffix"
}

# GitHub enforces a 244-byte limit on branch names
# Validate and truncate if necessary
$originalBranchName = "$featureNum-$branchSuffix"
$branchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
if ($branchName -ne $originalBranchName) {
[Console]::Error.WriteLine("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
[Console]::Error.WriteLine("[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)")
[Console]::Error.WriteLine("[specify] Truncated to: $branchName ($($branchName.Length) bytes)")
Expand Down
79 changes: 73 additions & 6 deletions scripts/python/create_new_feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def _help_text(argv0: str) -> str:
--dry-run Compute feature name and paths without creating directories or files
--allow-existing-branch Reuse an existing feature directory if it already exists
--short-name <name> Provide a custom short name (2-4 words) for the feature
--number N Specify branch number manually (overrides auto-detection)
--number N Prefer a feature number (auto-corrected if its specs prefix exists)
--timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering
--help, -h Show this help message

Expand Down Expand Up @@ -204,6 +204,43 @@ def _get_highest_from_specs(specs_dir: Path) -> int:
return highest


def _fit_branch_name(feature_num: str, branch_suffix: str) -> str:
"""Fit a feature prefix and suffix within GitHub's branch-name limit."""
branch_name = f"{feature_num}-{branch_suffix}"
if len(branch_name) <= _MAX_BRANCH_LENGTH:
return branch_name

max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
truncated_suffix = re.sub(r"-$", "", branch_suffix[:max_suffix_length])
return f"{feature_num}-{truncated_suffix}"


def _spec_prefix_exists(specs_dir: Path, feature_num: str) -> bool:
"""Return whether a spec directory owns the given numeric prefix."""
try:
return any(
entry.is_dir() and entry.name.startswith(f"{feature_num}-")
for entry in specs_dir.iterdir()
)
except OSError:
# Match Bash globbing and PowerShell's ErrorAction=SilentlyContinue.
return False


def _has_spec_prefix_conflict(
specs_dir: Path,
feature_num: str,
requested_dir: Path,
*,
allow_existing: bool,
) -> bool:
"""Return whether another spec directory owns the requested prefix."""
if allow_existing and requested_dir.is_dir():
return False

return _spec_prefix_exists(specs_dir, feature_num)


def main(argv: list[str] | None = None) -> int:
argv0 = sys.argv[0]
args = _parse_args(list(argv if argv is not None else sys.argv[1:]), argv0)
Expand Down Expand Up @@ -261,18 +298,48 @@ def main(argv: list[str] | None = None) -> int:
return 1
feature_num = f"{number:03d}"

# Treat an explicit number as a preference when its prefix is already used
# by a feature directory. Auto-detected numbers are already conflict-free.
if branch_number:
requested_branch_name = _fit_branch_name(feature_num, branch_suffix)
requested_dir = specs_dir / requested_branch_name
spec_conflict = _has_spec_prefix_conflict(
specs_dir,
feature_num,
requested_dir,
allow_existing=args.allow_existing,
)
if spec_conflict:
requested_num = feature_num
number = _get_highest_from_specs(specs_dir)
while True:
number += 1
if number > _MAX_FEATURE_NUMBER:
print(
f"Error: feature number must be between 0 and "
f"{_MAX_FEATURE_NUMBER}, got '{number}'",
file=sys.stderr,
)
return 1
feature_num = f"{number:03d}"
if not _spec_prefix_exists(specs_dir, feature_num):
break
print(
f"[specify] Warning: --number {requested_num} conflicts with "
f"an existing spec directory; using {feature_num} instead",
file=sys.stderr,
)

max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
if max_suffix_length <= 0:
print("Error: feature number is too long for a branch name", file=sys.stderr)
return 1

branch_name = f"{feature_num}-{branch_suffix}"
original_branch_name = f"{feature_num}-{branch_suffix}"
branch_name = _fit_branch_name(feature_num, branch_suffix)

# GitHub enforces a 244-byte limit on branch names.
if len(branch_name) > _MAX_BRANCH_LENGTH:
truncated_suffix = re.sub(r"-$", "", branch_suffix[:max_suffix_length])
original_branch_name = branch_name
branch_name = f"{feature_num}-{truncated_suffix}"
if branch_name != original_branch_name:
print(
"[specify] Warning: Branch name exceeded GitHub's 244-byte limit",
file=sys.stderr,
Expand Down
Loading