Skip to content

Commit 83e6df7

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 83e6df7

5 files changed

Lines changed: 391 additions & 58 deletions

File tree

scripts/bash/create-new-feature.sh

Lines changed: 53 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"
@@ -134,6 +137,23 @@ clean_branch_name() {
134137
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
135138
}
136139

140+
# Fit a feature prefix and suffix within GitHub's branch-name limit.
141+
fit_branch_name() {
142+
local feature_num="$1"
143+
local branch_suffix="$2"
144+
local branch_name="${feature_num}-${branch_suffix}"
145+
146+
if [ ${#branch_name} -gt $MAX_BRANCH_LENGTH ]; then
147+
local prefix_length=$(( ${#feature_num} + 1 ))
148+
local max_suffix_length=$((MAX_BRANCH_LENGTH - prefix_length))
149+
local truncated_suffix
150+
truncated_suffix=$(printf '%s' "$branch_suffix" | cut -c "1-$max_suffix_length" | sed 's/-$//')
151+
branch_name="${feature_num}-${truncated_suffix}"
152+
fi
153+
154+
printf '%s' "$branch_name"
155+
}
156+
137157
# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote
138158
# so the persistence hints match the Python variant exactly (printf %q output
139159
# differs between bash versions and from shlex.quote for spaces/metachars).
@@ -253,26 +273,42 @@ else
253273

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

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))
277+
# Treat an explicit number as a preference when its prefix is already used
278+
# by a feature directory. Auto-detected numbers are already conflict-free.
279+
if [ "$NUMBER_EXPLICIT" = true ]; then
280+
SPEC_CONFLICT=false
281+
REQUESTED_BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
282+
REQUESTED_DIR="$SPECS_DIR/$REQUESTED_BRANCH_NAME"
283+
if [ "$ALLOW_EXISTING" != true ] || [ ! -d "$REQUESTED_DIR" ]; then
284+
for spec_path in "$SPECS_DIR/${FEATURE_NUM}-"*; do
285+
if [ -d "$spec_path" ]; then
286+
SPEC_CONFLICT=true
287+
break
288+
fi
289+
done
290+
fi
267291

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/-$//')
292+
if [ "$SPEC_CONFLICT" = true ]; then
293+
REQUESTED_NUM="$FEATURE_NUM"
294+
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
295+
if [ "$HIGHEST" -eq "$MAX_FEATURE_NUMBER" ]; then
296+
echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2
297+
exit 1
298+
fi
299+
BRANCH_NUMBER=$((HIGHEST + 1))
300+
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
301+
>&2 echo "[specify] Warning: --number $REQUESTED_NUM conflicts with an existing spec directory; using $FEATURE_NUM instead"
302+
fi
303+
fi
272304

273-
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
274-
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
305+
fi
275306

307+
# GitHub enforces a 244-byte limit on branch names
308+
# Validate and truncate if necessary
309+
ORIGINAL_BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
310+
BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
311+
if [ "$BRANCH_NAME" != "$ORIGINAL_BRANCH_NAME" ]; then
276312
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
277313
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
278314
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"

scripts/powershell/create-new-feature.ps1

Lines changed: 49 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 ""
@@ -72,6 +73,24 @@ function ConvertTo-CleanBranchName {
7273

7374
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
7475
}
76+
77+
function Get-FittedBranchName {
78+
param(
79+
[string]$FeatureNum,
80+
[string]$BranchSuffix
81+
)
82+
83+
$fittedName = "$FeatureNum-$BranchSuffix"
84+
if ($fittedName.Length -gt $maxBranchLength) {
85+
$prefixLength = $FeatureNum.Length + 1
86+
$maxSuffixLength = $maxBranchLength - $prefixLength
87+
$truncatedSuffix = $BranchSuffix.Substring(0, [Math]::Min($BranchSuffix.Length, $maxSuffixLength))
88+
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
89+
$fittedName = "$FeatureNum-$truncatedSuffix"
90+
}
91+
92+
return $fittedName
93+
}
7594
# Load common functions (includes Get-RepoRoot and Resolve-Template)
7695
. "$PSScriptRoot/common.ps1"
7796

@@ -176,26 +195,39 @@ if ($Timestamp) {
176195
}
177196

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

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
199+
# Treat an explicit number as a preference when its prefix is already used
200+
# by a feature directory. Auto-detected numbers are already conflict-free.
201+
$specConflict = $false
202+
if ($hasNumber -and (Test-Path -LiteralPath $specsDir -PathType Container)) {
203+
$requestedBranchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
204+
$requestedDir = Join-Path $specsDir $requestedBranchName
205+
if (-not $AllowExistingBranch -or -not (Test-Path -LiteralPath $requestedDir -PathType Container)) {
206+
$specConflict = $null -ne (Get-ChildItem -LiteralPath $specsDir -Directory -ErrorAction SilentlyContinue |
207+
Where-Object { $_.Name -like "$featureNum-*" } |
208+
Select-Object -First 1)
209+
}
210+
}
190211

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 '-$', ''
212+
if ($specConflict) {
213+
$requestedNum = $featureNum
214+
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
215+
if ($highestNumber -eq [long]::MaxValue) {
216+
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
217+
exit 1
218+
}
219+
$resolvedNumber = $highestNumber + 1
220+
$featureNum = ('{0:000}' -f $resolvedNumber)
221+
[Console]::Error.WriteLine("[specify] Warning: -Number $requestedNum conflicts with an existing spec directory; using $featureNum instead")
222+
}
195223

196-
$originalBranchName = $branchName
197-
$branchName = "$featureNum-$truncatedSuffix"
224+
}
198225

226+
# GitHub enforces a 244-byte limit on branch names
227+
# Validate and truncate if necessary
228+
$originalBranchName = "$featureNum-$branchSuffix"
229+
$branchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
230+
if ($branchName -ne $originalBranchName) {
199231
[Console]::Error.WriteLine("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
200232
[Console]::Error.WriteLine("[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)")
201233
[Console]::Error.WriteLine("[specify] Truncated to: $branchName ($($branchName.Length) bytes)")

scripts/python/create_new_feature.py

Lines changed: 64 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,38 @@ 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 _has_spec_prefix_conflict(
219+
specs_dir: Path,
220+
feature_num: str,
221+
requested_dir: Path,
222+
*,
223+
allow_existing: bool,
224+
) -> bool:
225+
"""Return whether another spec directory owns the requested prefix."""
226+
if allow_existing and requested_dir.is_dir():
227+
return False
228+
229+
try:
230+
return any(
231+
entry.is_dir() and entry.name.startswith(f"{feature_num}-")
232+
for entry in specs_dir.iterdir()
233+
)
234+
except OSError:
235+
# Match Bash globbing and PowerShell's ErrorAction=SilentlyContinue.
236+
return False
237+
238+
207239
def main(argv: list[str] | None = None) -> int:
208240
argv0 = sys.argv[0]
209241
args = _parse_args(list(argv if argv is not None else sys.argv[1:]), argv0)
@@ -261,18 +293,44 @@ def main(argv: list[str] | None = None) -> int:
261293
return 1
262294
feature_num = f"{number:03d}"
263295

296+
# Treat an explicit number as a preference when its prefix is already used
297+
# by a feature directory. Auto-detected numbers are already conflict-free.
298+
if branch_number:
299+
requested_branch_name = _fit_branch_name(feature_num, branch_suffix)
300+
requested_dir = specs_dir / requested_branch_name
301+
spec_conflict = _has_spec_prefix_conflict(
302+
specs_dir,
303+
feature_num,
304+
requested_dir,
305+
allow_existing=args.allow_existing,
306+
)
307+
if spec_conflict:
308+
requested_num = feature_num
309+
number = _get_highest_from_specs(specs_dir) + 1
310+
if number > _MAX_FEATURE_NUMBER:
311+
print(
312+
f"Error: feature number must be between 0 and "
313+
f"{_MAX_FEATURE_NUMBER}, got '{number}'",
314+
file=sys.stderr,
315+
)
316+
return 1
317+
feature_num = f"{number:03d}"
318+
print(
319+
f"[specify] Warning: --number {requested_num} conflicts with "
320+
f"an existing spec directory; using {feature_num} instead",
321+
file=sys.stderr,
322+
)
323+
264324
max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
265325
if max_suffix_length <= 0:
266326
print("Error: feature number is too long for a branch name", file=sys.stderr)
267327
return 1
268328

269-
branch_name = f"{feature_num}-{branch_suffix}"
329+
original_branch_name = f"{feature_num}-{branch_suffix}"
330+
branch_name = _fit_branch_name(feature_num, branch_suffix)
270331

271332
# 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}"
333+
if branch_name != original_branch_name:
276334
print(
277335
"[specify] Warning: Branch name exceeded GitHub's 244-byte limit",
278336
file=sys.stderr,

0 commit comments

Comments
 (0)