mirror of
https://github.com/github/spec-kit.git
synced 2026-03-29 16:53:08 +00:00
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
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
JSON_MODE=false
|
JSON_MODE=false
|
||||||
|
ALLOW_EXISTING=false
|
||||||
SHORT_NAME=""
|
SHORT_NAME=""
|
||||||
BRANCH_NUMBER=""
|
BRANCH_NUMBER=""
|
||||||
USE_TIMESTAMP=false
|
USE_TIMESTAMP=false
|
||||||
@@ -14,6 +15,9 @@ while [ $i -le $# ]; do
|
|||||||
--json)
|
--json)
|
||||||
JSON_MODE=true
|
JSON_MODE=true
|
||||||
;;
|
;;
|
||||||
|
--allow-existing-branch)
|
||||||
|
ALLOW_EXISTING=true
|
||||||
|
;;
|
||||||
--short-name)
|
--short-name)
|
||||||
if [ $((i + 1)) -gt $# ]; then
|
if [ $((i + 1)) -gt $# ]; then
|
||||||
echo 'Error: --short-name requires a value' >&2
|
echo 'Error: --short-name requires a value' >&2
|
||||||
@@ -45,10 +49,11 @@ while [ $i -le $# ]; do
|
|||||||
USE_TIMESTAMP=true
|
USE_TIMESTAMP=true
|
||||||
;;
|
;;
|
||||||
--help|-h)
|
--help|-h)
|
||||||
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
|
echo "Usage: $0 [--json] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " --json Output in JSON format"
|
echo " --json Output in JSON format"
|
||||||
|
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
|
||||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
||||||
echo " --number N Specify branch number manually (overrides auto-detection)"
|
echo " --number N Specify branch number manually (overrides auto-detection)"
|
||||||
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||||
@@ -69,7 +74,7 @@ done
|
|||||||
|
|
||||||
FEATURE_DESCRIPTION="${ARGS[*]}"
|
FEATURE_DESCRIPTION="${ARGS[*]}"
|
||||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||||
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
|
echo "Usage: $0 [--json] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -287,12 +292,19 @@ if [ "$HAS_GIT" = true ]; then
|
|||||||
if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
|
if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
|
||||||
# Check if branch already exists
|
# Check if branch already exists
|
||||||
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
||||||
if [ "$USE_TIMESTAMP" = true ]; then
|
if [ "$ALLOW_EXISTING" = true ]; then
|
||||||
|
# Switch to the existing branch instead of failing
|
||||||
|
if ! git checkout "$BRANCH_NAME" 2>/dev/null; then
|
||||||
|
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
elif [ "$USE_TIMESTAMP" = true ]; then
|
||||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
|
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
|
||||||
|
exit 1
|
||||||
else
|
else
|
||||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
||||||
fi
|
|
||||||
exit 1
|
exit 1
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
|
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -305,14 +317,16 @@ fi
|
|||||||
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
||||||
mkdir -p "$FEATURE_DIR"
|
mkdir -p "$FEATURE_DIR"
|
||||||
|
|
||||||
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
|
|
||||||
SPEC_FILE="$FEATURE_DIR/spec.md"
|
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||||
|
if [ ! -f "$SPEC_FILE" ]; then
|
||||||
|
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
|
||||||
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
|
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
|
||||||
cp "$TEMPLATE" "$SPEC_FILE"
|
cp "$TEMPLATE" "$SPEC_FILE"
|
||||||
else
|
else
|
||||||
echo "Warning: Spec template not found; created empty spec file" >&2
|
echo "Warning: Spec template not found; created empty spec file" >&2
|
||||||
touch "$SPEC_FILE"
|
touch "$SPEC_FILE"
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Inform the user how to persist the feature variable in their own shell
|
# Inform the user how to persist the feature variable in their own shell
|
||||||
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[switch]$Json,
|
[switch]$Json,
|
||||||
|
[switch]$AllowExistingBranch,
|
||||||
[string]$ShortName,
|
[string]$ShortName,
|
||||||
[Parameter()]
|
[Parameter()]
|
||||||
[long]$Number = 0,
|
[long]$Number = 0,
|
||||||
@@ -15,10 +16,11 @@ $ErrorActionPreference = 'Stop'
|
|||||||
|
|
||||||
# Show help if requested
|
# Show help if requested
|
||||||
if ($Help) {
|
if ($Help) {
|
||||||
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Options:"
|
Write-Host "Options:"
|
||||||
Write-Host " -Json Output in JSON format"
|
Write-Host " -Json Output in JSON format"
|
||||||
|
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
|
||||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
||||||
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
||||||
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||||
@@ -33,7 +35,7 @@ if ($Help) {
|
|||||||
|
|
||||||
# Check if feature description provided
|
# Check if feature description provided
|
||||||
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
||||||
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,12 +253,20 @@ if ($hasGit) {
|
|||||||
# Check if branch already exists
|
# Check if branch already exists
|
||||||
$existingBranch = git branch --list $branchName 2>$null
|
$existingBranch = git branch --list $branchName 2>$null
|
||||||
if ($existingBranch) {
|
if ($existingBranch) {
|
||||||
if ($Timestamp) {
|
if ($AllowExistingBranch) {
|
||||||
|
# Switch to the existing branch instead of failing
|
||||||
|
git checkout -q $branchName 2>$null | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} elseif ($Timestamp) {
|
||||||
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
||||||
|
exit 1
|
||||||
} else {
|
} else {
|
||||||
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
||||||
}
|
|
||||||
exit 1
|
exit 1
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -269,13 +279,15 @@ if ($hasGit) {
|
|||||||
$featureDir = Join-Path $specsDir $branchName
|
$featureDir = Join-Path $specsDir $branchName
|
||||||
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
||||||
|
|
||||||
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
|
|
||||||
$specFile = Join-Path $featureDir 'spec.md'
|
$specFile = Join-Path $featureDir 'spec.md'
|
||||||
|
if (-not (Test-Path -PathType Leaf $specFile)) {
|
||||||
|
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
|
||||||
if ($template -and (Test-Path $template)) {
|
if ($template -and (Test-Path $template)) {
|
||||||
Copy-Item $template $specFile -Force
|
Copy-Item $template $specFile -Force
|
||||||
} else {
|
} else {
|
||||||
New-Item -ItemType File -Path $specFile | Out-Null
|
New-Item -ItemType File -Path $specFile | Out-Null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Set the SPECIFY_FEATURE environment variable for the current session
|
# Set the SPECIFY_FEATURE environment variable for the current session
|
||||||
$env:SPECIFY_FEATURE = $branchName
|
$env:SPECIFY_FEATURE = $branchName
|
||||||
|
|||||||
@@ -269,3 +269,146 @@ class TestE2EFlow:
|
|||||||
assert (git_repo / "specs" / branch).is_dir()
|
assert (git_repo / "specs" / branch).is_dir()
|
||||||
val = source_and_call(f'check_feature_branch "{branch}" "true"')
|
val = source_and_call(f'check_feature_branch "{branch}" "true"')
|
||||||
assert val.returncode == 0
|
assert val.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Allow Existing Branch Tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestAllowExistingBranch:
|
||||||
|
def test_allow_existing_switches_to_branch(self, git_repo: Path):
|
||||||
|
"""T006: Pre-create branch, verify script switches to it."""
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-b", "004-pre-exist"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
result = run_script(
|
||||||
|
git_repo, "--allow-existing-branch", "--short-name", "pre-exist",
|
||||||
|
"--number", "4", "Pre-existing feature",
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
current = subprocess.run(
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
cwd=git_repo, capture_output=True, text=True,
|
||||||
|
).stdout.strip()
|
||||||
|
assert current == "004-pre-exist", f"expected 004-pre-exist, got {current}"
|
||||||
|
|
||||||
|
def test_allow_existing_already_on_branch(self, git_repo: Path):
|
||||||
|
"""T007: Verify success when already on the target branch."""
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-b", "005-already-on"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
result = run_script(
|
||||||
|
git_repo, "--allow-existing-branch", "--short-name", "already-on",
|
||||||
|
"--number", "5", "Already on branch",
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
|
||||||
|
def test_allow_existing_creates_spec_dir(self, git_repo: Path):
|
||||||
|
"""T008: Verify spec directory created on existing branch."""
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-b", "006-spec-dir"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
result = run_script(
|
||||||
|
git_repo, "--allow-existing-branch", "--short-name", "spec-dir",
|
||||||
|
"--number", "6", "Spec dir feature",
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert (git_repo / "specs" / "006-spec-dir").is_dir()
|
||||||
|
assert (git_repo / "specs" / "006-spec-dir" / "spec.md").exists()
|
||||||
|
|
||||||
|
def test_without_flag_still_errors(self, git_repo: Path):
|
||||||
|
"""T009: Verify backwards compatibility (error without flag)."""
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-b", "007-no-flag"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
result = run_script(
|
||||||
|
git_repo, "--short-name", "no-flag", "--number", "7", "No flag feature",
|
||||||
|
)
|
||||||
|
assert result.returncode != 0, "should fail without --allow-existing-branch"
|
||||||
|
assert "already exists" in result.stderr
|
||||||
|
|
||||||
|
def test_allow_existing_no_overwrite_spec(self, git_repo: Path):
|
||||||
|
"""T010: Pre-create spec.md with content, verify it is preserved."""
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-b", "008-no-overwrite"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
spec_dir = git_repo / "specs" / "008-no-overwrite"
|
||||||
|
spec_dir.mkdir(parents=True)
|
||||||
|
spec_file = spec_dir / "spec.md"
|
||||||
|
spec_file.write_text("# My custom spec content\n")
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
result = run_script(
|
||||||
|
git_repo, "--allow-existing-branch", "--short-name", "no-overwrite",
|
||||||
|
"--number", "8", "No overwrite feature",
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert spec_file.read_text() == "# My custom spec content\n"
|
||||||
|
|
||||||
|
def test_allow_existing_creates_branch_if_not_exists(self, git_repo: Path):
|
||||||
|
"""T011: Verify normal creation when branch doesn't exist."""
|
||||||
|
result = run_script(
|
||||||
|
git_repo, "--allow-existing-branch", "--short-name", "new-branch",
|
||||||
|
"New branch feature",
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
current = subprocess.run(
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
cwd=git_repo, capture_output=True, text=True,
|
||||||
|
).stdout.strip()
|
||||||
|
assert "new-branch" in current
|
||||||
|
|
||||||
|
def test_allow_existing_with_json(self, git_repo: Path):
|
||||||
|
"""T012: Verify JSON output is correct."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-b", "009-json-test"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "checkout", "-"],
|
||||||
|
cwd=git_repo, check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
result = run_script(
|
||||||
|
git_repo, "--allow-existing-branch", "--json", "--short-name", "json-test",
|
||||||
|
"--number", "9", "JSON test",
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
assert data["BRANCH_NAME"] == "009-json-test"
|
||||||
|
|
||||||
|
def test_allow_existing_no_git(self, no_git_dir: Path):
|
||||||
|
"""T013: Verify flag is silently ignored in non-git repos."""
|
||||||
|
result = run_script(
|
||||||
|
no_git_dir, "--allow-existing-branch", "--short-name", "no-git",
|
||||||
|
"No git feature",
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
class TestAllowExistingBranchPowerShell:
|
||||||
|
def test_powershell_supports_allow_existing_branch_flag(self):
|
||||||
|
"""Static guard: PS script exposes and uses -AllowExistingBranch."""
|
||||||
|
contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
|
||||||
|
assert "-AllowExistingBranch" in contents
|
||||||
|
# Ensure the flag is referenced in script logic, not just declared
|
||||||
|
assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "")
|
||||||
|
|||||||
Reference in New Issue
Block a user