Skip to content

Commit bcdd421

Browse files
committed
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
1 parent 5be705e commit bcdd421

3 files changed

Lines changed: 172 additions & 19 deletions

File tree

scripts/bash/create-new-feature.sh

Lines changed: 21 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,16 @@ 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+
git checkout "$BRANCH_NAME" 2>/dev/null || true
298+
elif [ "$USE_TIMESTAMP" = true ]; then
291299
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
300+
exit 1
292301
else
293302
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
303+
exit 1
294304
fi
295-
exit 1
296305
else
297306
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
298307
exit 1
@@ -305,13 +314,15 @@ fi
305314
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
306315
mkdir -p "$FEATURE_DIR"
307316

308-
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
309317
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"
318+
if [ ! -f "$SPEC_FILE" ]; then
319+
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
320+
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
321+
cp "$TEMPLATE" "$SPEC_FILE"
322+
else
323+
echo "Warning: Spec template not found; created empty spec file" >&2
324+
touch "$SPEC_FILE"
325+
fi
315326
fi
316327

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

scripts/powershell/create-new-feature.ps1

Lines changed: 17 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,16 @@ 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+
} elseif ($Timestamp) {
255260
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
261+
exit 1
256262
} else {
257263
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
264+
exit 1
258265
}
259-
exit 1
260266
} else {
261267
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
262268
exit 1
@@ -269,12 +275,14 @@ if ($hasGit) {
269275
$featureDir = Join-Path $specsDir $branchName
270276
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
271277

272-
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
273278
$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
279+
if (-not (Test-Path $specFile)) {
280+
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
281+
if ($template -and (Test-Path $template)) {
282+
Copy-Item $template $specFile -Force
283+
} else {
284+
New-Item -ItemType File -Path $specFile | Out-Null
285+
}
278286
}
279287

280288
# Set the SPECIFY_FEATURE environment variable for the current session

tests/test_timestamp_branches.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,3 +269,137 @@ 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

0 commit comments

Comments
 (0)