Skip to content

Commit 8df0359

Browse files
committed
fix: auto-correct conflicting feature prefixes
Treat an explicit feature number as a preference when an existing spec directory already uses that prefix. Advance to the next available spec prefix and warn without fetching or scanning git branches. Keep Bash, PowerShell, and Python variants aligned. Preserve 64-bit numbering, timestamp mode, dry-run output, matching-file behavior, and exact-directory reuse through the allow-existing option. Assisted-by: Codex (model: GPT-5, autonomous)
1 parent 38eb2fc commit 8df0359

5 files changed

Lines changed: 455 additions & 58 deletions

File tree

scripts/bash/create-new-feature.sh

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ ALLOW_EXISTING=false
88
SHORT_NAME=""
99
BRANCH_NUMBER=""
1010
USE_TIMESTAMP=false
11+
NUMBER_EXPLICIT=false
1112
ARGS=()
1213
i=1
1314
while [ $i -le $# ]; do
@@ -48,6 +49,7 @@ while [ $i -le $# ]; do
4849
exit 1
4950
fi
5051
BRANCH_NUMBER="$next_arg"
52+
NUMBER_EXPLICIT=true
5153
;;
5254
--timestamp)
5355
USE_TIMESTAMP=true
@@ -60,7 +62,7 @@ while [ $i -le $# ]; do
6062
echo " --dry-run Compute feature name and paths without creating directories or files"
6163
echo " --allow-existing-branch Reuse an existing feature directory if it already exists"
6264
echo " --short-name <name> Provide a custom short name (2-4 words) for the feature"
63-
echo " --number N Specify branch number manually (overrides auto-detection)"
65+
echo " --number N Prefer a feature number (auto-corrected if its specs prefix exists)"
6466
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
6567
echo " --help, -h Show this help message"
6668
echo ""
@@ -91,6 +93,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
9193
fi
9294

9395
MAX_FEATURE_NUMBER=9223372036854775807
96+
MAX_BRANCH_LENGTH=244
9497

9598
is_feature_number_in_range() {
9699
local value="$1"
@@ -128,12 +131,40 @@ get_highest_from_specs() {
128131
echo "$highest"
129132
}
130133

134+
# Return success when a spec directory owns the given numeric prefix.
135+
spec_prefix_exists() {
136+
local specs_dir="$1"
137+
local feature_num="$2"
138+
139+
for spec_path in "$specs_dir/${feature_num}-"*; do
140+
[ -d "$spec_path" ] && return 0
141+
done
142+
return 1
143+
}
144+
131145
# Function to clean and format a branch name
132146
clean_branch_name() {
133147
local name="$1"
134148
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
135149
}
136150

151+
# Fit a feature prefix and suffix within GitHub's branch-name limit.
152+
fit_branch_name() {
153+
local feature_num="$1"
154+
local branch_suffix="$2"
155+
local branch_name="${feature_num}-${branch_suffix}"
156+
157+
if [ ${#branch_name} -gt $MAX_BRANCH_LENGTH ]; then
158+
local prefix_length=$(( ${#feature_num} + 1 ))
159+
local max_suffix_length=$((MAX_BRANCH_LENGTH - prefix_length))
160+
local truncated_suffix
161+
truncated_suffix=$(printf '%s' "$branch_suffix" | cut -c "1-$max_suffix_length" | sed 's/-$//')
162+
branch_name="${feature_num}-${truncated_suffix}"
163+
fi
164+
165+
printf '%s' "$branch_name"
166+
}
167+
137168
# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote
138169
# so the persistence hints match the Python variant exactly (printf %q output
139170
# differs between bash versions and from shlex.quote for spaces/metachars).
@@ -253,26 +284,41 @@ else
253284

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

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

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

273-
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
274-
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
315+
fi
275316

317+
# GitHub enforces a 244-byte limit on branch names
318+
# Validate and truncate if necessary
319+
ORIGINAL_BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
320+
BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
321+
if [ "$BRANCH_NAME" != "$ORIGINAL_BRANCH_NAME" ]; then
276322
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
277323
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
278324
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"

scripts/powershell/create-new-feature.ps1

Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ param(
1414
[string[]]$FeatureDescription
1515
)
1616
$ErrorActionPreference = 'Stop'
17+
$maxBranchLength = 244
1718

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

71+
function Test-SpecPrefixInUse {
72+
param(
73+
[string]$SpecsDir,
74+
[string]$FeatureNum
75+
)
76+
77+
if (-not (Test-Path -LiteralPath $SpecsDir -PathType Container)) {
78+
return $false
79+
}
80+
81+
return $null -ne (Get-ChildItem -LiteralPath $SpecsDir -Directory -ErrorAction SilentlyContinue |
82+
Where-Object { $_.Name -like "$FeatureNum-*" } |
83+
Select-Object -First 1)
84+
}
85+
7086
function ConvertTo-CleanBranchName {
7187
param([string]$Name)
7288

7389
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
7490
}
91+
92+
function Get-FittedBranchName {
93+
param(
94+
[string]$FeatureNum,
95+
[string]$BranchSuffix
96+
)
97+
98+
$fittedName = "$FeatureNum-$BranchSuffix"
99+
if ($fittedName.Length -gt $maxBranchLength) {
100+
$prefixLength = $FeatureNum.Length + 1
101+
$maxSuffixLength = $maxBranchLength - $prefixLength
102+
$truncatedSuffix = $BranchSuffix.Substring(0, [Math]::Min($BranchSuffix.Length, $maxSuffixLength))
103+
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
104+
$fittedName = "$FeatureNum-$truncatedSuffix"
105+
}
106+
107+
return $fittedName
108+
}
75109
# Load common functions (includes Get-RepoRoot and Resolve-Template)
76110
. "$PSScriptRoot/common.ps1"
77111

@@ -176,26 +210,40 @@ if ($Timestamp) {
176210
}
177211

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

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

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

196-
$originalBranchName = $branchName
197-
$branchName = "$featureNum-$truncatedSuffix"
240+
}
198241

242+
# GitHub enforces a 244-byte limit on branch names
243+
# Validate and truncate if necessary
244+
$originalBranchName = "$featureNum-$branchSuffix"
245+
$branchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
246+
if ($branchName -ne $originalBranchName) {
199247
[Console]::Error.WriteLine("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
200248
[Console]::Error.WriteLine("[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)")
201249
[Console]::Error.WriteLine("[specify] Truncated to: $branchName ($($branchName.Length) bytes)")

scripts/python/create_new_feature.py

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def _help_text(argv0: str) -> str:
7676
--dry-run Compute feature name and paths without creating directories or files
7777
--allow-existing-branch Reuse an existing feature directory if it already exists
7878
--short-name <name> Provide a custom short name (2-4 words) for the feature
79-
--number N Specify branch number manually (overrides auto-detection)
79+
--number N Prefer a feature number (auto-corrected if its specs prefix exists)
8080
--timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering
8181
--help, -h Show this help message
8282
@@ -204,6 +204,43 @@ def _get_highest_from_specs(specs_dir: Path) -> int:
204204
return highest
205205

206206

207+
def _fit_branch_name(feature_num: str, branch_suffix: str) -> str:
208+
"""Fit a feature prefix and suffix within GitHub's branch-name limit."""
209+
branch_name = f"{feature_num}-{branch_suffix}"
210+
if len(branch_name) <= _MAX_BRANCH_LENGTH:
211+
return branch_name
212+
213+
max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
214+
truncated_suffix = re.sub(r"-$", "", branch_suffix[:max_suffix_length])
215+
return f"{feature_num}-{truncated_suffix}"
216+
217+
218+
def _spec_prefix_exists(specs_dir: Path, feature_num: str) -> bool:
219+
"""Return whether a spec directory owns the given numeric prefix."""
220+
try:
221+
return any(
222+
entry.is_dir() and entry.name.startswith(f"{feature_num}-")
223+
for entry in specs_dir.iterdir()
224+
)
225+
except OSError:
226+
# Match Bash globbing and PowerShell's ErrorAction=SilentlyContinue.
227+
return False
228+
229+
230+
def _has_spec_prefix_conflict(
231+
specs_dir: Path,
232+
feature_num: str,
233+
requested_dir: Path,
234+
*,
235+
allow_existing: bool,
236+
) -> bool:
237+
"""Return whether another spec directory owns the requested prefix."""
238+
if allow_existing and requested_dir.is_dir():
239+
return False
240+
241+
return _spec_prefix_exists(specs_dir, feature_num)
242+
243+
207244
def main(argv: list[str] | None = None) -> int:
208245
argv0 = sys.argv[0]
209246
args = _parse_args(list(argv if argv is not None else sys.argv[1:]), argv0)
@@ -261,18 +298,48 @@ def main(argv: list[str] | None = None) -> int:
261298
return 1
262299
feature_num = f"{number:03d}"
263300

301+
# Treat an explicit number as a preference when its prefix is already used
302+
# by a feature directory. Auto-detected numbers are already conflict-free.
303+
if branch_number:
304+
requested_branch_name = _fit_branch_name(feature_num, branch_suffix)
305+
requested_dir = specs_dir / requested_branch_name
306+
spec_conflict = _has_spec_prefix_conflict(
307+
specs_dir,
308+
feature_num,
309+
requested_dir,
310+
allow_existing=args.allow_existing,
311+
)
312+
if spec_conflict:
313+
requested_num = feature_num
314+
number = _get_highest_from_specs(specs_dir)
315+
while True:
316+
number += 1
317+
if number > _MAX_FEATURE_NUMBER:
318+
print(
319+
f"Error: feature number must be between 0 and "
320+
f"{_MAX_FEATURE_NUMBER}, got '{number}'",
321+
file=sys.stderr,
322+
)
323+
return 1
324+
feature_num = f"{number:03d}"
325+
if not _spec_prefix_exists(specs_dir, feature_num):
326+
break
327+
print(
328+
f"[specify] Warning: --number {requested_num} conflicts with "
329+
f"an existing spec directory; using {feature_num} instead",
330+
file=sys.stderr,
331+
)
332+
264333
max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
265334
if max_suffix_length <= 0:
266335
print("Error: feature number is too long for a branch name", file=sys.stderr)
267336
return 1
268337

269-
branch_name = f"{feature_num}-{branch_suffix}"
338+
original_branch_name = f"{feature_num}-{branch_suffix}"
339+
branch_name = _fit_branch_name(feature_num, branch_suffix)
270340

271341
# GitHub enforces a 244-byte limit on branch names.
272-
if len(branch_name) > _MAX_BRANCH_LENGTH:
273-
truncated_suffix = re.sub(r"-$", "", branch_suffix[:max_suffix_length])
274-
original_branch_name = branch_name
275-
branch_name = f"{feature_num}-{truncated_suffix}"
342+
if branch_name != original_branch_name:
276343
print(
277344
"[specify] Warning: Branch name exceeded GitHub's 244-byte limit",
278345
file=sys.stderr,

0 commit comments

Comments
 (0)