Skip to content

Commit bfe4772

Browse files
fix: auto-correct conflicting feature prefixes (#1829)
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 c0ba811 commit bfe4772

5 files changed

Lines changed: 462 additions & 58 deletions

File tree

scripts/bash/create-new-feature.sh

Lines changed: 65 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,9 @@ while [ $i -le $# ]; do
4849
exit 1
4950
fi
5051
BRANCH_NUMBER="$next_arg"
52+
if [ -n "$BRANCH_NUMBER" ]; then
53+
NUMBER_EXPLICIT=true
54+
fi
5155
;;
5256
--timestamp)
5357
USE_TIMESTAMP=true
@@ -60,7 +64,7 @@ while [ $i -le $# ]; do
6064
echo " --dry-run Compute feature name and paths without creating directories or files"
6165
echo " --allow-existing-branch Reuse an existing feature directory if it already exists"
6266
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)"
67+
echo " --number N Prefer a feature number (auto-corrected if its specs prefix exists)"
6468
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
6569
echo " --help, -h Show this help message"
6670
echo ""
@@ -91,6 +95,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
9195
fi
9296

9397
MAX_FEATURE_NUMBER=9223372036854775807
98+
MAX_BRANCH_LENGTH=244
9499

95100
is_feature_number_in_range() {
96101
local value="$1"
@@ -128,12 +133,40 @@ get_highest_from_specs() {
128133
echo "$highest"
129134
}
130135

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

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

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

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))
290+
# Treat an explicit number as a preference when its prefix is already used
291+
# by a feature directory. Auto-detected numbers are already conflict-free.
292+
if [ "$NUMBER_EXPLICIT" = true ]; then
293+
SPEC_CONFLICT=false
294+
REQUESTED_BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
295+
REQUESTED_DIR="$SPECS_DIR/$REQUESTED_BRANCH_NAME"
296+
if [ "$ALLOW_EXISTING" != true ] || [ ! -d "$REQUESTED_DIR" ]; then
297+
spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" && SPEC_CONFLICT=true
298+
fi
267299

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/-$//')
300+
if [ "$SPEC_CONFLICT" = true ]; then
301+
REQUESTED_NUM="$FEATURE_NUM"
302+
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
303+
BRANCH_NUMBER=$HIGHEST
304+
while true; do
305+
if [ "$BRANCH_NUMBER" -eq "$MAX_FEATURE_NUMBER" ]; then
306+
echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2
307+
exit 1
308+
fi
309+
BRANCH_NUMBER=$((BRANCH_NUMBER + 1))
310+
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
311+
spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" || break
312+
done
313+
>&2 echo "[specify] Warning: --number $REQUESTED_NUM conflicts with an existing spec directory; using $FEATURE_NUM instead"
314+
fi
315+
fi
272316

273-
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
274-
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
317+
fi
275318

319+
# GitHub enforces a 244-byte limit on branch names
320+
# Validate and truncate if necessary
321+
ORIGINAL_BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
322+
BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
323+
if [ "$BRANCH_NAME" != "$ORIGINAL_BRANCH_NAME" ]; then
276324
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
277325
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
278326
>&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)