From 2564aefa94535fd89798c525fbbe1311f13bb397 Mon Sep 17 00:00:00 2001 From: VeeraBabu Jakkula Date: Fri, 30 Oct 2026 23:59:37 +0530 Subject: [PATCH] fix(extensions): stop the git extension's fetch from waiting on a credential prompt Before it numbers a new feature branch, the git extension runs `git fetch --all --prune` with its output redirected. Git writes credential prompts to the terminal, not to stdout or stderr, so an HTTPS remote without a usable credential helper makes the fetch wait for a username that nobody types. speckit.git.feature is the mandatory before_specify hook, so /speckit.specify appears to hang in agent sessions. The dry-run path already ran ls-remote with GIT_TERMINAL_PROMPT=0; the fetch path did not. Set GIT_TERMINAL_PROMPT=0 for that fetch in the Bash, PowerShell and Python scripts. The PowerShell script restores the caller's value (or removes the variable when it was unset). A failed fetch is still ignored, as before. Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous) Co-Authored-By: Claude Opus 5.5 --- .../scripts/bash/create-new-feature-branch.sh | 2 +- .../powershell/create-new-feature-branch.ps1 | 7 +- .../python/create_new_feature_branch.py | 1 + tests/extensions/git/test_git_extension.py | 110 ++++++++++++++++++ .../git/test_git_extension_python_parity.py | 23 ++++ 5 files changed, 141 insertions(+), 2 deletions(-) diff --git a/extensions/git/scripts/bash/create-new-feature-branch.sh b/extensions/git/scripts/bash/create-new-feature-branch.sh index 5303bb97c9..d951151664 100755 --- a/extensions/git/scripts/bash/create-new-feature-branch.sh +++ b/extensions/git/scripts/bash/create-new-feature-branch.sh @@ -191,7 +191,7 @@ check_existing_branches() { highest_branch=$highest_remote fi else - git fetch --all --prune >/dev/null 2>&1 || true + GIT_TERMINAL_PROMPT=0 git fetch --all --prune >/dev/null 2>&1 || true local highest_branch=$(get_highest_from_branches "$scope_prefix") fi diff --git a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 index 4d06a113a4..54452b6999 100644 --- a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 @@ -162,9 +162,14 @@ function Get-NextBranchNumber { $highestRemote = Get-HighestNumberFromRemoteRefs -ScopePrefix $ScopePrefix $highestBranch = [Math]::Max($highestBranch, $highestRemote) } else { + $previousTerminalPrompt = $env:GIT_TERMINAL_PROMPT try { + $env:GIT_TERMINAL_PROMPT = '0' git fetch --all --prune 2>$null | Out-Null - } catch { } + } catch { + } finally { + $env:GIT_TERMINAL_PROMPT = $previousTerminalPrompt + } $highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix } diff --git a/extensions/git/scripts/python/create_new_feature_branch.py b/extensions/git/scripts/python/create_new_feature_branch.py index 202b1f796e..0cfb6a5c09 100644 --- a/extensions/git/scripts/python/create_new_feature_branch.py +++ b/extensions/git/scripts/python/create_new_feature_branch.py @@ -271,6 +271,7 @@ def check_existing_branches( cwd=repo_root, capture_output=True, text=True, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, ) highest_branch = get_highest_from_branches(repo_root, scope_prefix) diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 1760e496d1..f734dbb772 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -100,6 +100,40 @@ def _add_sibling_worktree(project: Path, path: Path, branch: str) -> None: ) +def _add_prompt_probe_remote(project: Path) -> Path: + """Add an ssh remote whose transport logs GIT_TERMINAL_PROMPT and fails. + + Git runs core.sshCommand with the environment of the fetch or ls-remote + that contacts the remote, so the log shows whether git was allowed to + prompt for credentials. No network is used. Returns the log path. + """ + git_dir = project / ".git" + probe = git_dir / "prompt-probe.sh" + probe.write_text( + "#!/bin/sh\n" + 'echo "${GIT_TERMINAL_PROMPT-unset}" >> "$(dirname "$0")/prompt-probe.log"\n' + "exit 1\n", + encoding="utf-8", + newline="\n", + ) + subprocess.run( + ["git", "remote", "add", "origin", "ssh://git@example.invalid/repo.git"], + cwd=project, + check=True, + ) + subprocess.run( + ["git", "config", "core.sshCommand", f'sh "{probe.as_posix()}"'], + cwd=project, + check=True, + ) + return git_dir / "prompt-probe.log" + + +def _probe_log(log: Path) -> list[str]: + """GIT_TERMINAL_PROMPT values seen by the probe remote, one per contact.""" + return log.read_text(encoding="utf-8").split() if log.exists() else [] + + # Git identity env vars for CI runners without global git config _GIT_ENV = { "GIT_AUTHOR_NAME": "Test User", @@ -583,6 +617,31 @@ def test_dry_run_preserves_literal_plus_branch_prefix(self, tmp_path: Path): assert data["BRANCH_NAME"] == "001-next" assert data["FEATURE_NUM"] == "001" + @pytest.mark.parametrize("extra_args", [(), ("--dry-run",)], ids=["fetch", "dry-run"]) + def test_remote_lookup_disables_terminal_prompt( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, extra_args: tuple[str, ...] + ): + """Branch numbering must not wait on a git credential prompt (#5199). + + The redirects hide git's output but not a prompt written to the + terminal, so the fetch and the dry-run ls-remote both need + GIT_TERMINAL_PROMPT=0. A failing remote must not stop the script. + """ + monkeypatch.delenv("GIT_TERMINAL_PROMPT", raising=False) + project = _setup_project(tmp_path) + log = _add_prompt_probe_remote(project) + + result = _run_bash( + "create-new-feature-branch.sh", project, + "--json", *extra_args, "--short-name", "probe", "Probe feature", + ) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout)["BRANCH_NAME"] == "001-probe" + calls = _probe_log(log) + assert calls, "the probe remote was never contacted" + assert set(calls) == {"0"} + def test_no_git_graceful_degradation(self, tmp_path: Path): """create-new-feature-branch.sh works without git (outputs branch name, skips branch creation).""" project = _setup_project(tmp_path, git=False) @@ -934,6 +993,57 @@ def test_git_branch_name_override_without_feature_marker_preserves_full_name(sel assert data["BRANCH_NAME"] == "jdoe/app-a/custom-branch" assert data["FEATURE_NUM"] == "jdoe/app-a/custom-branch" + @pytest.mark.parametrize("extra_args", [(), ("-DryRun",)], ids=["fetch", "dry-run"]) + def test_remote_lookup_disables_terminal_prompt( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, extra_args: tuple[str, ...] + ): + """Branch numbering must not wait on a git credential prompt (#5199).""" + monkeypatch.delenv("GIT_TERMINAL_PROMPT", raising=False) + project = _setup_project(tmp_path) + log = _add_prompt_probe_remote(project) + + result = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", *extra_args, "-ShortName", "probe", "Probe feature", + ) + + assert result.returncode == 0, result.stderr + json_line = [ln for ln in result.stdout.splitlines() if ln.strip().startswith("{")] + assert json_line, f"No JSON in output: {result.stdout}" + assert json.loads(json_line[-1])["BRANCH_NAME"] == "001-probe" + calls = _probe_log(log) + assert calls, "the probe remote was never contacted" + assert set(calls) == {"0"} + + @pytest.mark.parametrize("caller_value", ["1", None], ids=["set", "unset"]) + def test_fetch_restores_caller_terminal_prompt( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caller_value: str | None + ): + """The fetch guard must not leak into, or clear, the caller's GIT_TERMINAL_PROMPT.""" + if caller_value is None: + monkeypatch.delenv("GIT_TERMINAL_PROMPT", raising=False) + else: + monkeypatch.setenv("GIT_TERMINAL_PROMPT", caller_value) + project = _setup_project(tmp_path) + log = _add_prompt_probe_remote(project) + script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1" + command = ( + f"& '{script}' -Json -ShortName probe 'Probe feature' | Out-Null; " + "'after=' + [Environment]::GetEnvironmentVariable('GIT_TERMINAL_PROMPT')" + ) + env = {**os.environ, **_GIT_ENV} + result = subprocess.run( + ["pwsh", "-NoProfile", "-Command", command], + cwd=project, + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode == 0, result.stderr + assert set(_probe_log(log)) == {"0"} + assert result.stdout.splitlines()[-1] == f"after={caller_value or ''}" + def test_no_git_graceful_degradation(self, tmp_path: Path): """create-new-feature-branch.ps1 works without git.""" project = _setup_project(tmp_path, git=False) diff --git a/tests/extensions/git/test_git_extension_python_parity.py b/tests/extensions/git/test_git_extension_python_parity.py index 8cae45e783..64d9bdbe19 100644 --- a/tests/extensions/git/test_git_extension_python_parity.py +++ b/tests/extensions/git/test_git_extension_python_parity.py @@ -21,7 +21,9 @@ from tests.extensions.git.test_git_extension import ( _GIT_ENV, HAS_PWSH, + _add_prompt_probe_remote, _init_git, + _probe_log, _run_bash, _run_pwsh, _setup_project, @@ -292,6 +294,27 @@ def test_numbering_from_specs_and_branches(self, tmp_path: Path): _assert_parity(b, p) assert json.loads(p.stdout)["FEATURE_NUM"] == "013" + def test_fetch_disables_terminal_prompt( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + """Both twins fetch with GIT_TERMINAL_PROMPT=0 and survive a failing remote (#5199).""" + monkeypatch.delenv("GIT_TERMINAL_PROMPT", raising=False) + bash_proj, py_proj = _twin_projects(tmp_path) + bash_log = _add_prompt_probe_remote(bash_proj) + py_log = _add_prompt_probe_remote(py_proj) + b = _run_bash( + "create-new-feature-branch.sh", bash_proj, "--json", "--short-name", "probe", "Probe feature" + ) + p = _run_py( + "create-new-feature-branch", py_proj, "--json", "--short-name", "probe", "Probe feature" + ) + _assert_parity(b, p) + assert json.loads(p.stdout)["BRANCH_NAME"] == "001-probe" + for log in (bash_log, py_log): + calls = _probe_log(log) + assert calls, "the probe remote was never contacted" + assert set(calls) == {"0"} + def test_explicit_number(self, tmp_path: Path): bash_proj, py_proj = _twin_projects(tmp_path) b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--number", "42", "some feature")