Skip to content

Commit 05bf2bf

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 05bf2bf

5 files changed

Lines changed: 219 additions & 21 deletions

File tree

scripts/bash/create-new-feature.sh

Lines changed: 32 additions & 1 deletion
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 ""
@@ -253,6 +255,35 @@ else
253255

254256
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
255257
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
258+
259+
# Treat an explicit number as a preference when its prefix is already used
260+
# by a feature directory. Auto-detected numbers are already conflict-free.
261+
if [ "$NUMBER_EXPLICIT" = true ]; then
262+
SPEC_CONFLICT=false
263+
REQUESTED_DIR="$SPECS_DIR/${FEATURE_NUM}-${BRANCH_SUFFIX}"
264+
for spec_path in "$SPECS_DIR/${FEATURE_NUM}-"*; do
265+
if [ -d "$spec_path" ]; then
266+
if [ "$ALLOW_EXISTING" = true ] && [ "$spec_path" = "$REQUESTED_DIR" ]; then
267+
continue
268+
fi
269+
SPEC_CONFLICT=true
270+
break
271+
fi
272+
done
273+
274+
if [ "$SPEC_CONFLICT" = true ]; then
275+
REQUESTED_NUM="$FEATURE_NUM"
276+
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
277+
if [ "$HIGHEST" -eq "$MAX_FEATURE_NUMBER" ]; then
278+
echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2
279+
exit 1
280+
fi
281+
BRANCH_NUMBER=$((HIGHEST + 1))
282+
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
283+
>&2 echo "[specify] Warning: --number $REQUESTED_NUM conflicts with an existing spec directory; using $FEATURE_NUM instead"
284+
fi
285+
fi
286+
256287
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
257288
fi
258289

scripts/powershell/create-new-feature.ps1

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ if ($Help) {
2424
Write-Host " -DryRun Compute feature name and paths without creating directories or files"
2525
Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
2626
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)"
27+
Write-Host " -Number N Prefer a feature number (auto-corrected if its specs prefix exists)"
2828
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
2929
Write-Host " -Help Show this help message"
3030
Write-Host ""
@@ -176,6 +176,32 @@ if ($Timestamp) {
176176
}
177177

178178
$featureNum = ('{0:000}' -f $resolvedNumber)
179+
180+
# Treat an explicit number as a preference when its prefix is already used
181+
# by a feature directory. Auto-detected numbers are already conflict-free.
182+
$specConflict = $false
183+
if ($hasNumber -and (Test-Path -LiteralPath $specsDir -PathType Container)) {
184+
$requestedDir = Join-Path $specsDir "$featureNum-$branchSuffix"
185+
$specConflict = $null -ne (Get-ChildItem -LiteralPath $specsDir -Directory -ErrorAction SilentlyContinue |
186+
Where-Object {
187+
$_.Name -like "$featureNum-*" -and
188+
(-not $AllowExistingBranch -or $_.FullName -ne $requestedDir)
189+
} |
190+
Select-Object -First 1)
191+
}
192+
193+
if ($specConflict) {
194+
$requestedNum = $featureNum
195+
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
196+
if ($highestNumber -eq [long]::MaxValue) {
197+
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
198+
exit 1
199+
}
200+
$resolvedNumber = $highestNumber + 1
201+
$featureNum = ('{0:000}' -f $resolvedNumber)
202+
[Console]::Error.WriteLine("[specify] Warning: -Number $requestedNum conflicts with an existing spec directory; using $featureNum instead")
203+
}
204+
179205
$branchName = "$featureNum-$branchSuffix"
180206
}
181207

scripts/python/create_new_feature.py

Lines changed: 28 additions & 1 deletion
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
@@ -261,6 +261,33 @@ def main(argv: list[str] | None = None) -> int:
261261
return 1
262262
feature_num = f"{number:03d}"
263263

264+
# Treat an explicit number as a preference when its prefix is already used
265+
# by a feature directory. Auto-detected numbers are already conflict-free.
266+
if branch_number and specs_dir.is_dir():
267+
requested_dir = specs_dir / f"{feature_num}-{branch_suffix}"
268+
spec_conflict = any(
269+
entry.is_dir()
270+
and entry.name.startswith(f"{feature_num}-")
271+
and not (args.allow_existing and entry == requested_dir)
272+
for entry in specs_dir.iterdir()
273+
)
274+
if spec_conflict:
275+
requested_num = feature_num
276+
number = _get_highest_from_specs(specs_dir) + 1
277+
if number > _MAX_FEATURE_NUMBER:
278+
print(
279+
f"Error: feature number must be between 0 and "
280+
f"{_MAX_FEATURE_NUMBER}, got '{number}'",
281+
file=sys.stderr,
282+
)
283+
return 1
284+
feature_num = f"{number:03d}"
285+
print(
286+
f"[specify] Warning: --number {requested_num} conflicts with "
287+
f"an existing spec directory; using {feature_num} instead",
288+
file=sys.stderr,
289+
)
290+
264291
max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
265292
if max_suffix_length <= 0:
266293
print("Error: feature number is too long for a branch name", file=sys.stderr)

tests/test_create_new_feature_python_parity.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,7 @@ def test_python_missing_template_warning_matches_bash(
355355

356356

357357
@requires_bash
358-
def test_python_existing_directory_error_matches_bash(
358+
def test_python_existing_prefix_auto_correct_matches_bash(
359359
repo_pair: tuple[Path, Path],
360360
) -> None:
361361
repo_a, repo_b = repo_pair
@@ -377,8 +377,8 @@ def test_python_existing_directory_error_matches_bash(
377377
bash = run(bash_cmd(repo_a, SCRIPT, "--json", "--number", "1", description), repo_a)
378378
py = run(py_cmd(repo_b, SCRIPT, "--json", "--number", "1", description), repo_b)
379379

380-
assert py.returncode == bash.returncode == 1
381-
assert py.stdout == bash.stdout == ""
380+
assert py.returncode == bash.returncode == 0
381+
assert json_stdout(py)["FEATURE_NUM"] == json_stdout(bash)["FEATURE_NUM"] == "002"
382382
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
383383
bash.stderr, repo_a
384384
)
@@ -819,19 +819,24 @@ def test_all_variants_allow_existing_branch(repo: Path) -> None:
819819

820820
@requires_bash
821821
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
822-
def test_all_variants_existing_directory_failure_diagnostics(repo: Path) -> None:
822+
def test_all_variants_existing_prefix_auto_correct_diagnostics(repo: Path) -> None:
823823
(repo / "specs" / "001-x").mkdir(parents=True)
824-
expected = (
825-
"Error: Feature directory '<REPO>/specs/001-x' already exists. "
826-
"Please use a different feature name or specify a different number "
827-
"with --number."
828-
)
824+
expected = "conflicts with an existing spec directory; using 002 instead"
829825

830-
bash = run(bash_cmd(repo, SCRIPT, "--json", "--number", "1", "x"), repo)
831-
ps = run(ps_cmd(repo, SCRIPT, "-Json", "-Number", "1", "x"), repo)
832-
py = run(py_cmd(repo, SCRIPT, "--json", "--number", "1", "x"), repo)
826+
bash = run(
827+
bash_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", "1", "x"),
828+
repo,
829+
)
830+
ps = run(
831+
ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "-Number", "1", "x"),
832+
repo,
833+
)
834+
py = run(
835+
py_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", "1", "x"),
836+
repo,
837+
)
833838

834-
assert bash.returncode == ps.returncode == py.returncode == 1
835-
assert bash.stdout == ps.stdout == py.stdout == ""
839+
assert bash.returncode == ps.returncode == py.returncode == 0
840+
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
836841
for result in (bash, ps, py):
837842
assert expected in _normalized_error_text(result.stderr, repo)

tests/test_timestamp_branches.py

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,50 @@ def test_explicit_number_zero_is_honored(self, git_repo: Path):
288288
assert data["FEATURE_NUM"] == "000"
289289
assert data["BRANCH_NAME"] == "000-zero"
290290

291+
def test_explicit_conflicting_number_uses_next_spec_prefix(self, git_repo: Path):
292+
"""An explicit number is advanced when its spec prefix already exists."""
293+
(git_repo / "specs" / "001-existing").mkdir(parents=True)
294+
(git_repo / "specs" / "1000-latest").mkdir()
295+
296+
result = run_script(
297+
git_repo,
298+
"--json",
299+
"--dry-run",
300+
"--number",
301+
"1",
302+
"--short-name",
303+
"test",
304+
"Test feature",
305+
)
306+
307+
assert result.returncode == 0, result.stderr
308+
data = json.loads(result.stdout)
309+
assert data["FEATURE_NUM"] == "1001"
310+
assert data["BRANCH_NAME"] == "1001-test"
311+
assert "--number 001 conflicts with an existing spec directory" in result.stderr
312+
assert "using 1001 instead" in result.stderr
313+
314+
def test_explicit_number_ignores_matching_file(self, git_repo: Path):
315+
"""A matching file does not count as a conflicting spec directory."""
316+
specs_dir = git_repo / "specs"
317+
specs_dir.mkdir()
318+
(specs_dir / "001-placeholder").write_text("not a directory", encoding="utf-8")
319+
320+
result = run_script(
321+
git_repo,
322+
"--json",
323+
"--dry-run",
324+
"--number",
325+
"1",
326+
"--short-name",
327+
"test",
328+
"Test feature",
329+
)
330+
331+
assert result.returncode == 0, result.stderr
332+
assert json.loads(result.stdout)["FEATURE_NUM"] == "001"
333+
assert "conflicts with an existing spec directory" not in result.stderr
334+
291335

292336
class TestSequentialBranchPowerShell:
293337
def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self):
@@ -332,6 +376,50 @@ def test_explicit_number_zero_is_honored_matching_bash(self, ps_git_repo: Path):
332376
assert data["FEATURE_NUM"] == "000"
333377
assert data["BRANCH_NAME"] == "000-zero"
334378

379+
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
380+
def test_explicit_conflicting_number_uses_next_spec_prefix(
381+
self, ps_git_repo: Path
382+
):
383+
"""PowerShell advances an explicit number when its spec prefix exists."""
384+
script = ps_git_repo / "scripts" / "powershell" / "create-new-feature.ps1"
385+
(ps_git_repo / "specs" / "001-existing").mkdir(parents=True)
386+
(ps_git_repo / "specs" / "1000-latest").mkdir()
387+
388+
result = subprocess.run(
389+
[
390+
"pwsh", "-NoProfile", "-File", str(script), "-Json", "-DryRun",
391+
"-Number", "1", "-ShortName", "test", "Test feature",
392+
],
393+
cwd=ps_git_repo, capture_output=True, text=True,
394+
)
395+
396+
assert result.returncode == 0, result.stderr
397+
data = json.loads(result.stdout)
398+
assert data["FEATURE_NUM"] == "1001"
399+
assert data["BRANCH_NAME"] == "1001-test"
400+
assert "-Number 001 conflicts with an existing spec directory" in result.stderr
401+
assert "using 1001 instead" in result.stderr
402+
403+
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
404+
def test_explicit_number_ignores_matching_file(self, ps_git_repo: Path):
405+
"""PowerShell ignores files that resemble numbered spec directories."""
406+
script = ps_git_repo / "scripts" / "powershell" / "create-new-feature.ps1"
407+
specs_dir = ps_git_repo / "specs"
408+
specs_dir.mkdir()
409+
(specs_dir / "001-placeholder").write_text("not a directory", encoding="utf-8")
410+
411+
result = subprocess.run(
412+
[
413+
"pwsh", "-NoProfile", "-File", str(script), "-Json", "-DryRun",
414+
"-Number", "1", "-ShortName", "test", "Test feature",
415+
],
416+
cwd=ps_git_repo, capture_output=True, text=True,
417+
)
418+
419+
assert result.returncode == 0, result.stderr
420+
assert json.loads(result.stdout)["FEATURE_NUM"] == "001"
421+
assert "conflicts with an existing spec directory" not in result.stderr
422+
335423
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
336424
def test_missing_spec_template_warns_matching_bash(self, ps_git_repo: Path):
337425
"""When no spec template can be resolved, create-new-feature.ps1 must warn on
@@ -531,14 +619,15 @@ def test_allow_existing_reuses_existing_feature_dir(self, git_repo: Path):
531619
assert feature_dir.is_dir()
532620
assert (feature_dir / "spec.md").exists()
533621

534-
def test_without_flag_still_errors(self, git_repo: Path):
535-
"""T009: Existing feature directories still fail without the flag."""
622+
def test_without_flag_auto_corrects_existing_prefix(self, git_repo: Path):
623+
"""T009: Existing prefix advances when exact reuse is not allowed."""
536624
(git_repo / "specs" / "007-no-flag").mkdir(parents=True)
537625
result = run_script(
538626
git_repo, "--short-name", "no-flag", "--number", "7", "No flag feature",
539627
)
540-
assert result.returncode != 0, "should fail without --allow-existing-branch"
541-
assert "already exists" in result.stderr
628+
assert result.returncode == 0, result.stderr
629+
assert (git_repo / "specs" / "008-no-flag").is_dir()
630+
assert "using 008 instead" in result.stderr
542631

543632
def test_allow_existing_no_overwrite_spec(self, git_repo: Path):
544633
"""T010: Pre-create spec.md with content, verify it is preserved."""
@@ -598,6 +687,26 @@ def test_powershell_reuses_existing_feature_dir(self):
598687
assert "Feature directory '$featureDir' already exists" in contents
599688
assert "-not $AllowExistingBranch" in contents
600689

690+
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
691+
def test_powershell_reuses_exact_feature_dir(self, ps_git_repo: Path):
692+
"""PowerShell exact-directory reuse bypasses prefix auto-correction."""
693+
script = ps_git_repo / "scripts" / "powershell" / "create-new-feature.ps1"
694+
feature_dir = ps_git_repo / "specs" / "004-pre-exist"
695+
feature_dir.mkdir(parents=True)
696+
697+
result = subprocess.run(
698+
[
699+
"pwsh", "-NoProfile", "-File", str(script), "-Json",
700+
"-AllowExistingBranch", "-Number", "4", "-ShortName",
701+
"pre-exist", "Pre-existing feature",
702+
],
703+
cwd=ps_git_repo, capture_output=True, text=True,
704+
)
705+
706+
assert result.returncode == 0, result.stderr
707+
assert json.loads(result.stdout)["BRANCH_NAME"] == "004-pre-exist"
708+
assert (feature_dir / "spec.md").is_file()
709+
601710
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
602711
@pytest.mark.skipif(
603712
os.name != "nt" or shutil.which("powershell.exe") is None,

0 commit comments

Comments
 (0)