Skip to content

Commit f8da535

Browse files
authored
feat(scripts): add --allow-existing-branch flag to create-new-feature (#1999)
* feat(scripts): add --allow-existing-branch flag to create-new-feature Add an --allow-existing-branch / -AllowExistingBranch flag to both bash and PowerShell create-new-feature scripts. When the target branch already exists, the script switches to it instead of failing. The spec directory and template are still created if missing, but existing spec.md files are not overwritten (prevents data loss on re-runs). The flag is opt-in, so existing behavior is completely unchanged without it. This enables worktree-based workflows and CI/CD pipelines that create branches externally before running speckit.specify. Relates to #1931. Also addresses #1680, #841, #1921. Assisted-By: 🤖 Claude Code * fix: address PR review feedback for allow-existing-branch - Make checkout failure fatal instead of suppressing with || true (bash) - Check $LASTEXITCODE after git checkout in PowerShell - Use Test-Path -PathType Leaf for spec file existence check (PS) - Add PowerShell static assertion test for -AllowExistingBranch flag Assisted-By: 🤖 Claude Code
1 parent edaa5a7 commit f8da535

File tree

3 files changed

+188
-19
lines changed

3 files changed

+188
-19
lines changed

scripts/bash/create-new-feature.sh

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
set -e
44

55
JSON_MODE=false
6+
ALLOW_EXISTING=false
67
SHORT_NAME=""
78
BRANCH_NUMBER=""
89
USE_TIMESTAMP=false
@@ -14,6 +15,9 @@ while [ $i -le $# ]; do
1415
--json)
1516
JSON_MODE=true
1617
;;
18+
--allow-existing-branch)
19+
ALLOW_EXISTING=true
20+
;;
1721
--short-name)
1822
if [ $((i + 1)) -gt $# ]; then
1923
echo 'Error: --short-name requires a value' >&2
@@ -45,10 +49,11 @@ while [ $i -le $# ]; do
4549
USE_TIMESTAMP=true
4650
;;
4751
--help|-h)
48-
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
52+
echo "Usage: $0 [--json] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
4953
echo ""
5054
echo "Options:"
5155
echo " --json Output in JSON format"
56+
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
5257
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
5358
echo " --number N Specify branch number manually (overrides auto-detection)"
5459
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
@@ -69,7 +74,7 @@ done
6974

7075
FEATURE_DESCRIPTION="${ARGS[*]}"
7176
if [ -z "$FEATURE_DESCRIPTION" ]; then
72-
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
77+
echo "Usage: $0 [--json] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
7378
exit 1
7479
fi
7580

@@ -287,12 +292,19 @@ if [ "$HAS_GIT" = true ]; then
287292
if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
288293
# Check if branch already exists
289294
if git branch --list "$BRANCH_NAME" | grep -q .; then
290-
if [ "$USE_TIMESTAMP" = true ]; then
295+
if [ "$ALLOW_EXISTING" = true ]; then
296+
# Switch to the existing branch instead of failing
297+
if ! git checkout "$BRANCH_NAME" 2>/dev/null; then
298+
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
299+
exit 1
300+
fi
301+
elif [ "$USE_TIMESTAMP" = true ]; then
291302
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
303+
exit 1
292304
else
293305
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
306+
exit 1
294307
fi
295-
exit 1
296308
else
297309
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
298310
exit 1
@@ -305,13 +317,15 @@ fi
305317
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
306318
mkdir -p "$FEATURE_DIR"
307319

308-
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
309320
SPEC_FILE="$FEATURE_DIR/spec.md"
310-
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
311-
cp "$TEMPLATE" "$SPEC_FILE"
312-
else
313-
echo "Warning: Spec template not found; created empty spec file" >&2
314-
touch "$SPEC_FILE"
321+
if [ ! -f "$SPEC_FILE" ]; then
322+
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
323+
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
324+
cp "$TEMPLATE" "$SPEC_FILE"
325+
else
326+
echo "Warning: Spec template not found; created empty spec file" >&2
327+
touch "$SPEC_FILE"
328+
fi
315329
fi
316330

317331
# Inform the user how to persist the feature variable in their own shell

scripts/powershell/create-new-feature.ps1

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
[CmdletBinding()]
44
param(
55
[switch]$Json,
6+
[switch]$AllowExistingBranch,
67
[string]$ShortName,
78
[Parameter()]
89
[long]$Number = 0,
@@ -15,10 +16,11 @@ $ErrorActionPreference = 'Stop'
1516

1617
# Show help if requested
1718
if ($Help) {
18-
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
19+
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
1920
Write-Host ""
2021
Write-Host "Options:"
2122
Write-Host " -Json Output in JSON format"
23+
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
2224
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
2325
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
2426
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
@@ -33,7 +35,7 @@ if ($Help) {
3335

3436
# Check if feature description provided
3537
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
36-
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
38+
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
3739
exit 1
3840
}
3941

@@ -251,12 +253,20 @@ if ($hasGit) {
251253
# Check if branch already exists
252254
$existingBranch = git branch --list $branchName 2>$null
253255
if ($existingBranch) {
254-
if ($Timestamp) {
256+
if ($AllowExistingBranch) {
257+
# Switch to the existing branch instead of failing
258+
git checkout -q $branchName 2>$null | Out-Null
259+
if ($LASTEXITCODE -ne 0) {
260+
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
261+
exit 1
262+
}
263+
} elseif ($Timestamp) {
255264
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
265+
exit 1
256266
} else {
257267
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
268+
exit 1
258269
}
259-
exit 1
260270
} else {
261271
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
262272
exit 1
@@ -269,12 +279,14 @@ if ($hasGit) {
269279
$featureDir = Join-Path $specsDir $branchName
270280
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
271281

272-
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
273282
$specFile = Join-Path $featureDir 'spec.md'
274-
if ($template -and (Test-Path $template)) {
275-
Copy-Item $template $specFile -Force
276-
} else {
277-
New-Item -ItemType File -Path $specFile | Out-Null
283+
if (-not (Test-Path -PathType Leaf $specFile)) {
284+
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
285+
if ($template -and (Test-Path $template)) {
286+
Copy-Item $template $specFile -Force
287+
} else {
288+
New-Item -ItemType File -Path $specFile | Out-Null
289+
}
278290
}
279291

280292
# Set the SPECIFY_FEATURE environment variable for the current session

tests/test_timestamp_branches.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,3 +269,146 @@ def test_e2e_sequential(self, git_repo: Path):
269269
assert (git_repo / "specs" / branch).is_dir()
270270
val = source_and_call(f'check_feature_branch "{branch}" "true"')
271271
assert val.returncode == 0
272+
273+
274+
# ── Allow Existing Branch Tests ──────────────────────────────────────────────
275+
276+
277+
class TestAllowExistingBranch:
278+
def test_allow_existing_switches_to_branch(self, git_repo: Path):
279+
"""T006: Pre-create branch, verify script switches to it."""
280+
subprocess.run(
281+
["git", "checkout", "-b", "004-pre-exist"],
282+
cwd=git_repo, check=True, capture_output=True,
283+
)
284+
subprocess.run(
285+
["git", "checkout", "-"],
286+
cwd=git_repo, check=True, capture_output=True,
287+
)
288+
result = run_script(
289+
git_repo, "--allow-existing-branch", "--short-name", "pre-exist",
290+
"--number", "4", "Pre-existing feature",
291+
)
292+
assert result.returncode == 0, result.stderr
293+
current = subprocess.run(
294+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
295+
cwd=git_repo, capture_output=True, text=True,
296+
).stdout.strip()
297+
assert current == "004-pre-exist", f"expected 004-pre-exist, got {current}"
298+
299+
def test_allow_existing_already_on_branch(self, git_repo: Path):
300+
"""T007: Verify success when already on the target branch."""
301+
subprocess.run(
302+
["git", "checkout", "-b", "005-already-on"],
303+
cwd=git_repo, check=True, capture_output=True,
304+
)
305+
result = run_script(
306+
git_repo, "--allow-existing-branch", "--short-name", "already-on",
307+
"--number", "5", "Already on branch",
308+
)
309+
assert result.returncode == 0, result.stderr
310+
311+
def test_allow_existing_creates_spec_dir(self, git_repo: Path):
312+
"""T008: Verify spec directory created on existing branch."""
313+
subprocess.run(
314+
["git", "checkout", "-b", "006-spec-dir"],
315+
cwd=git_repo, check=True, capture_output=True,
316+
)
317+
subprocess.run(
318+
["git", "checkout", "-"],
319+
cwd=git_repo, check=True, capture_output=True,
320+
)
321+
result = run_script(
322+
git_repo, "--allow-existing-branch", "--short-name", "spec-dir",
323+
"--number", "6", "Spec dir feature",
324+
)
325+
assert result.returncode == 0, result.stderr
326+
assert (git_repo / "specs" / "006-spec-dir").is_dir()
327+
assert (git_repo / "specs" / "006-spec-dir" / "spec.md").exists()
328+
329+
def test_without_flag_still_errors(self, git_repo: Path):
330+
"""T009: Verify backwards compatibility (error without flag)."""
331+
subprocess.run(
332+
["git", "checkout", "-b", "007-no-flag"],
333+
cwd=git_repo, check=True, capture_output=True,
334+
)
335+
subprocess.run(
336+
["git", "checkout", "-"],
337+
cwd=git_repo, check=True, capture_output=True,
338+
)
339+
result = run_script(
340+
git_repo, "--short-name", "no-flag", "--number", "7", "No flag feature",
341+
)
342+
assert result.returncode != 0, "should fail without --allow-existing-branch"
343+
assert "already exists" in result.stderr
344+
345+
def test_allow_existing_no_overwrite_spec(self, git_repo: Path):
346+
"""T010: Pre-create spec.md with content, verify it is preserved."""
347+
subprocess.run(
348+
["git", "checkout", "-b", "008-no-overwrite"],
349+
cwd=git_repo, check=True, capture_output=True,
350+
)
351+
spec_dir = git_repo / "specs" / "008-no-overwrite"
352+
spec_dir.mkdir(parents=True)
353+
spec_file = spec_dir / "spec.md"
354+
spec_file.write_text("# My custom spec content\n")
355+
subprocess.run(
356+
["git", "checkout", "-"],
357+
cwd=git_repo, check=True, capture_output=True,
358+
)
359+
result = run_script(
360+
git_repo, "--allow-existing-branch", "--short-name", "no-overwrite",
361+
"--number", "8", "No overwrite feature",
362+
)
363+
assert result.returncode == 0, result.stderr
364+
assert spec_file.read_text() == "# My custom spec content\n"
365+
366+
def test_allow_existing_creates_branch_if_not_exists(self, git_repo: Path):
367+
"""T011: Verify normal creation when branch doesn't exist."""
368+
result = run_script(
369+
git_repo, "--allow-existing-branch", "--short-name", "new-branch",
370+
"New branch feature",
371+
)
372+
assert result.returncode == 0, result.stderr
373+
current = subprocess.run(
374+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
375+
cwd=git_repo, capture_output=True, text=True,
376+
).stdout.strip()
377+
assert "new-branch" in current
378+
379+
def test_allow_existing_with_json(self, git_repo: Path):
380+
"""T012: Verify JSON output is correct."""
381+
import json
382+
383+
subprocess.run(
384+
["git", "checkout", "-b", "009-json-test"],
385+
cwd=git_repo, check=True, capture_output=True,
386+
)
387+
subprocess.run(
388+
["git", "checkout", "-"],
389+
cwd=git_repo, check=True, capture_output=True,
390+
)
391+
result = run_script(
392+
git_repo, "--allow-existing-branch", "--json", "--short-name", "json-test",
393+
"--number", "9", "JSON test",
394+
)
395+
assert result.returncode == 0, result.stderr
396+
data = json.loads(result.stdout)
397+
assert data["BRANCH_NAME"] == "009-json-test"
398+
399+
def test_allow_existing_no_git(self, no_git_dir: Path):
400+
"""T013: Verify flag is silently ignored in non-git repos."""
401+
result = run_script(
402+
no_git_dir, "--allow-existing-branch", "--short-name", "no-git",
403+
"No git feature",
404+
)
405+
assert result.returncode == 0, result.stderr
406+
407+
408+
class TestAllowExistingBranchPowerShell:
409+
def test_powershell_supports_allow_existing_branch_flag(self):
410+
"""Static guard: PS script exposes and uses -AllowExistingBranch."""
411+
contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
412+
assert "-AllowExistingBranch" in contents
413+
# Ensure the flag is referenced in script logic, not just declared
414+
assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "")

0 commit comments

Comments
 (0)