diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md index 01f664f84b..5654d4723d 100644 --- a/extensions/git/commands/speckit.git.feature.md +++ b/extensions/git/commands/speckit.git.feature.md @@ -19,6 +19,7 @@ You **MUST** consider the user input before proceeding (if not empty). If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set: - The script uses the exact value as the branch name, bypassing all prefix/suffix generation - `--short-name`, `--number`, and `--timestamp` flags are ignored +- If the value names the branch that is already checked out, the script succeeds without switching or creating a branch - `FEATURE_NUM` is extracted when the final path segment starts with a numeric or timestamp feature marker (for example `042-name`, `feat/042-name`, or `jdoe/app/042-name`), otherwise set to the full branch name ## Prerequisites diff --git a/extensions/git/scripts/bash/create-new-feature-branch.sh b/extensions/git/scripts/bash/create-new-feature-branch.sh index 5303bb97c9..4f8f0f248a 100755 --- a/extensions/git/scripts/bash/create-new-feature-branch.sh +++ b/extensions/git/scripts/bash/create-new-feature-branch.sh @@ -569,7 +569,9 @@ if [ "$DRY_RUN" != true ]; then if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" if git branch --list "$BRANCH_NAME" | grep -q .; then - if [ "$ALLOW_EXISTING" = true ]; then + # An explicit GIT_BRANCH_NAME naming the checked-out branch is + # already satisfied, so treat it like --allow-existing-branch. + if [ "$ALLOW_EXISTING" = true ] || { [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$current_branch" = "$BRANCH_NAME" ]; }; then if [ "$current_branch" = "$BRANCH_NAME" ]; then : elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then diff --git a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 index 4d06a113a4..172f356166 100644 --- a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 @@ -526,7 +526,9 @@ if (-not $DryRun) { try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {} $existingBranch = git branch --list $branchName 2>$null if ($existingBranch) { - if ($AllowExistingBranch) { + # An explicit GIT_BRANCH_NAME naming the checked-out branch is + # already satisfied, so treat it like -AllowExistingBranch. + if ($AllowExistingBranch -or ($env:GIT_BRANCH_NAME -and $currentBranch -ceq $branchName)) { if ($currentBranch -eq $branchName) { # Already on the target branch } else { diff --git a/extensions/git/scripts/python/create_new_feature_branch.py b/extensions/git/scripts/python/create_new_feature_branch.py index 202b1f796e..5eae2a1d0d 100644 --- a/extensions/git/scripts/python/create_new_feature_branch.py +++ b/extensions/git/scripts/python/create_new_feature_branch.py @@ -574,7 +574,11 @@ def build_branch_name(feature_num: str, branch_suffix: str) -> str: _git_lines(repo_root, "branch", "--list", branch_name) ) if branch_exists: - if args.allow_existing: + # An explicit GIT_BRANCH_NAME naming the checked-out branch is + # already satisfied, so treat it like --allow-existing-branch. + if args.allow_existing or ( + env_branch_name and current_branch == branch_name + ): if current_branch != branch_name: switch = subprocess.run( ["git", "checkout", "-q", branch_name], diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 1760e496d1..2e65b79ce4 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -500,6 +500,28 @@ def test_branch_template_rejects_slug_before_number(self, tmp_path: Path): assert "{slug}" in result.stderr assert "{number}" in result.stderr + def test_git_branch_name_override_naming_current_branch_is_noop(self, tmp_path: Path): + """GIT_BRANCH_NAME equal to the checked-out branch exits 0 without switching.""" + project = _setup_project(tmp_path) + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/PROJ-42-login"], + cwd=project, check=True, + ) + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--short-name", "login", "Login page", + env_extra={"GIT_BRANCH_NAME": "feature/PROJ-42-login"}, + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "feature/PROJ-42-login" + head = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=project, capture_output=True, text=True, check=True, + ).stdout.strip() + assert head == "feature/PROJ-42-login" + def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path): """GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch.""" project = _setup_project(tmp_path / "app-a") @@ -920,6 +942,28 @@ def test_git_branch_name_override_ignores_numeric_namespace_segments(self, tmp_p assert data["BRANCH_NAME"] == "jdoe/2026-app/042-custom-branch" assert data["FEATURE_NUM"] == "042" + def test_git_branch_name_override_naming_current_branch_is_noop(self, tmp_path: Path): + """PowerShell GIT_BRANCH_NAME equal to the checked-out branch exits 0 without switching.""" + project = _setup_project(tmp_path) + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/PROJ-42-login"], + cwd=project, check=True, + ) + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-ShortName", "login", "Login page", + env_extra={"GIT_BRANCH_NAME": "feature/PROJ-42-login"}, + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "feature/PROJ-42-login" + head = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=project, capture_output=True, text=True, check=True, + ).stdout.strip() + assert head == "feature/PROJ-42-login" + def test_git_branch_name_override_without_feature_marker_preserves_full_name(self, tmp_path: Path): """PowerShell keeps the full override name when no feature marker exists.""" project = _setup_project(tmp_path / "app-a") diff --git a/tests/extensions/git/test_git_extension_python_parity.py b/tests/extensions/git/test_git_extension_python_parity.py index 8cae45e783..90e3dd4d96 100644 --- a/tests/extensions/git/test_git_extension_python_parity.py +++ b/tests/extensions/git/test_git_extension_python_parity.py @@ -379,6 +379,38 @@ def test_git_branch_name_override(self, tmp_path: Path): _assert_parity(b, p) assert json.loads(p.stdout) == {"BRANCH_NAME": "team/042-exact-name", "FEATURE_NUM": "042"} + def test_git_branch_name_override_naming_current_branch_is_noop( + self, tmp_path: Path + ): + bash_proj, py_proj = _twin_projects(tmp_path) + env = {"GIT_BRANCH_NAME": "feature/PROJ-42-login"} + for proj in (bash_proj, py_proj): + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/PROJ-42-login"], + cwd=proj, check=True, + ) + b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "desc word", env_extra=env) + p = _run_py("create-new-feature-branch", py_proj, "--json", "desc word", env_extra=env) + _assert_parity(b, p) + assert p.returncode == 0, p.stderr + assert json.loads(p.stdout) == { + "BRANCH_NAME": "feature/PROJ-42-login", + "FEATURE_NUM": "feature/PROJ-42-login", + } + + def test_git_branch_name_override_naming_other_existing_branch_fails( + self, tmp_path: Path + ): + bash_proj, py_proj = _twin_projects(tmp_path) + env = {"GIT_BRANCH_NAME": "feature/other"} + for proj in (bash_proj, py_proj): + subprocess.run(["git", "branch", "feature/other"], cwd=proj, check=True) + b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "desc word", env_extra=env) + p = _run_py("create-new-feature-branch", py_proj, "--json", "desc word", env_extra=env) + _assert_parity(b, p) + assert p.returncode == 1 + assert "already exists" in p.stderr + def test_git_branch_name_override_persist_hint_matches_bash( self, tmp_path: Path ):