From 0d650f1aa72200daa97ea783857d0ab1e92fb8c3 Mon Sep 17 00:00:00 2001 From: xizhilanre <767269555@qq.com> Date: Mon, 2 Nov 2026 23:01:44 +0800 Subject: [PATCH 1/3] fix(git): stop the feature-branch fetch from blocking on a credential prompt (#5199) check_existing_branches runs `git fetch --all --prune` for non-dry-run invocations without `GIT_TERMINAL_PROMPT=0`. When a remote is HTTPS and has no usable credential helper, git prompts on the tty, and because `speckit.git.feature` runs as the mandatory `before_specify` hook, `/speckit.specify` blocks instead of continuing. The dry-run path already passes the guard to `git ls-remote`; the real fetch path did not, in any of the three twins. Apply the same guard to bash, PowerShell and Python so the fetch fails fast and the script continues. Refs #5199 Assisted-by: DeepSeek Harness (model: deepseek-v4.1-flash, autonomous) --- .../scripts/bash/create-new-feature-branch.sh | 5 +- .../powershell/create-new-feature-branch.ps1 | 4 + .../python/create_new_feature_branch.py | 4 + tests/test_git_fetch_prompt_guard.py | 128 ++++++++++++++++++ 4 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 tests/test_git_fetch_prompt_guard.py diff --git a/extensions/git/scripts/bash/create-new-feature-branch.sh b/extensions/git/scripts/bash/create-new-feature-branch.sh index 5303bb97c9..3b19edc4bc 100755 --- a/extensions/git/scripts/bash/create-new-feature-branch.sh +++ b/extensions/git/scripts/bash/create-new-feature-branch.sh @@ -191,7 +191,10 @@ check_existing_branches() { highest_branch=$highest_remote fi else - git fetch --all --prune >/dev/null 2>&1 || true + # The dry-run path already sets this for `git ls-remote`. Without it, + # an HTTPS remote with no usable credential helper makes git prompt on + # the tty, and /speckit.specify hangs instead of continuing. + 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..b97a1cc9c2 100644 --- a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 @@ -162,9 +162,13 @@ function Get-NextBranchNumber { $highestRemote = Get-HighestNumberFromRemoteRefs -ScopePrefix $ScopePrefix $highestBranch = [Math]::Max($highestBranch, $highestRemote) } else { + # Mirror the ls-remote guard: an HTTPS remote without a credential + # helper would otherwise prompt on the tty and block the caller. + $env:GIT_TERMINAL_PROMPT = '0' try { git fetch --all --prune 2>$null | Out-Null } catch { } + $env:GIT_TERMINAL_PROMPT = $null $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..dd95a10338 100644 --- a/extensions/git/scripts/python/create_new_feature_branch.py +++ b/extensions/git/scripts/python/create_new_feature_branch.py @@ -266,11 +266,15 @@ def check_existing_branches( get_highest_from_branches(repo_root, scope_prefix), ) else: + # The dry-run path already passes GIT_TERMINAL_PROMPT=0 to ls-remote. + # Without it here, an HTTPS remote with no usable credential helper + # prompts on the tty and /speckit.specify hangs instead of continuing. subprocess.run( ["git", "fetch", "--all", "--prune"], 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/test_git_fetch_prompt_guard.py b/tests/test_git_fetch_prompt_guard.py new file mode 100644 index 0000000000..be541eb8a6 --- /dev/null +++ b/tests/test_git_fetch_prompt_guard.py @@ -0,0 +1,128 @@ +"""Regression tests for the git extension's feature-branch fetch guard (#5199). + +`check_existing_branches` runs `git fetch --all --prune` for non-dry-run +invocations. Without ``GIT_TERMINAL_PROMPT=0`` an HTTPS remote that has no +usable credential helper makes git prompt on the tty, so the mandatory +``before_specify`` hook (and therefore ``/speckit.specify``) blocks instead of +continuing. The dry-run path already guarded ``git ls-remote``; these tests pin +the same guard on the real fetch path in all three implementations. +""" + +import importlib.util +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from tests.conftest import requires_bash + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +EXT_SCRIPTS = PROJECT_ROOT / "extensions" / "git" / "scripts" +PY_SCRIPT = EXT_SCRIPTS / "python" / "create_new_feature_branch.py" +BASH_SCRIPT = EXT_SCRIPTS / "bash" / "create-new-feature-branch.sh" +PS1_SCRIPT = EXT_SCRIPTS / "powershell" / "create-new-feature-branch.ps1" + +HAS_PWSH = shutil.which("pwsh") is not None + + +def _load_python_script(): + spec = importlib.util.spec_from_file_location( + "ext_create_new_feature_branch", PY_SCRIPT + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + # dataclasses resolves annotations through sys.modules[cls.__module__]. + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _init_repo(path: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=path, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=path, check=True + ) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=path, check=True) + + +def _run(command, cwd: Path, log: Path): + env = {**os.environ, "STUB_LOG": str(log)} + env.pop("GIT_TERMINAL_PROMPT", None) + return subprocess.run(command, cwd=cwd, env=env, capture_output=True, text=True) + + +def test_python_fetch_disables_terminal_prompt(tmp_path, monkeypatch): + module = _load_python_script() + specs_dir = tmp_path / "specs" + specs_dir.mkdir() + + calls = [] + + class _Result: + returncode = 0 + stdout = "" + stderr = "" + + def fake_run(cmd, **kwargs): + calls.append((list(cmd), kwargs)) + return _Result() + + monkeypatch.setattr(module.subprocess, "run", fake_run) + module.check_existing_branches(tmp_path, specs_dir, False, "") + + fetch_calls = [call for call in calls if call[0][:2] == ["git", "fetch"]] + assert len(fetch_calls) == 1, calls + assert fetch_calls[0][1]["env"]["GIT_TERMINAL_PROMPT"] == "0" + + +@requires_bash +def test_bash_fetch_disables_terminal_prompt(tmp_path): + _init_repo(tmp_path) + log = tmp_path / "prompt.log" + harness = tmp_path / "harness.sh" + harness.write_text( + "#!/usr/bin/env bash\n" + "git() {\n" + ' if [ "$1" = fetch ]; then\n' + ' printf \'%s\' "${GIT_TERMINAL_PROMPT-}" > "$STUB_LOG"\n' + " return 0\n" + " fi\n" + ' command git "$@"\n' + "}\n" + "export -f git\n" + f'exec bash "{BASH_SCRIPT.as_posix()}" --short-name probe-fetch-guard "fetch guard"\n', + encoding="utf-8", + ) + + _run(["bash", str(harness)], cwd=tmp_path, log=log) + + assert log.read_text(encoding="utf-8") == "0" + + +@pytest.mark.skipif(not HAS_PWSH, reason="pwsh is not available") +def test_powershell_fetch_disables_terminal_prompt(tmp_path): + _init_repo(tmp_path) + log = tmp_path / "prompt.log" + harness = tmp_path / "harness.ps1" + harness.write_text( + "function git {\n" + " if ($args[0] -eq 'fetch') {\n" + f" Set-Content -NoNewline -Path '{log}' -Value \"$($env:GIT_TERMINAL_PROMPT)\"\n" + " return\n" + " }\n" + " $real = (Get-Command git.exe -CommandType Application).Source\n" + " & $real @args\n" + "}\n" + f". '{PS1_SCRIPT}' -ShortName 'probe-fetch-guard' 'fetch guard'\n", + encoding="utf-8", + ) + + _run(["pwsh", "-NoProfile", "-File", str(harness)], cwd=tmp_path, log=log) + + assert log.read_text(encoding="utf-8") == "0" + + + From b424751b48d66ddfcec750a995e8b77b646009c4 Mon Sep 17 00:00:00 2001 From: xizhilanre <767269555@qq.com> Date: Tue, 3 Nov 2026 00:08:44 +0800 Subject: [PATCH 2/3] chore(git): bump the bundled git extension to 1.0.2 for the fetch guard (#5199) The version-bump gate rejects content changes under `extensions/git/` without a manifest bump, because installed copies only receive updates when `extension.version` increases. Bump `extension.yml` and the matching `catalog.json` entry from 1.0.1 to 1.0.2, and drop the stray blank line at the end of the new regression test (`git diff --check`). Assisted-by: DeepSeek Harness (model: deepseek-v4.1-flash, autonomous) --- extensions/catalog.json | 2 +- extensions/git/extension.yml | 2 +- tests/test_git_fetch_prompt_guard.py | 3 --- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/extensions/catalog.json b/extensions/catalog.json index fed3f08842..4194e8084d 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -51,7 +51,7 @@ "git": { "name": "Git Branching Workflow", "id": "git", - "version": "1.0.1", + "version": "1.0.2", "description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", diff --git a/extensions/git/extension.yml b/extensions/git/extension.yml index 90a7e56769..2d762a126a 100644 --- a/extensions/git/extension.yml +++ b/extensions/git/extension.yml @@ -3,7 +3,7 @@ schema_version: "1.0" extension: id: git name: "Git Branching Workflow" - version: "1.0.1" + version: "1.0.2" description: "Feature branch creation, numbering (sequential/timestamp), templating, validation, and Git remote detection" author: spec-kit-core repository: https://github.com/github/spec-kit diff --git a/tests/test_git_fetch_prompt_guard.py b/tests/test_git_fetch_prompt_guard.py index be541eb8a6..28f19be5eb 100644 --- a/tests/test_git_fetch_prompt_guard.py +++ b/tests/test_git_fetch_prompt_guard.py @@ -123,6 +123,3 @@ def test_powershell_fetch_disables_terminal_prompt(tmp_path): _run(["pwsh", "-NoProfile", "-File", str(harness)], cwd=tmp_path, log=log) assert log.read_text(encoding="utf-8") == "0" - - - From 37513d5488fdbcc57c704c71302151baef15a139 Mon Sep 17 00:00:00 2001 From: xizhilanre <767269555@qq.com> Date: Tue, 3 Nov 2026 00:10:36 +0800 Subject: [PATCH 3/3] test(git): run the fetch-guard harness against an isolated project copy The extension scripts resolve their project root from their own location, so the bash and PowerShell cases ran the checkout's copy and created feature branches in this repository while testing. Install the scripts into the temp project's `.specify/scripts` and run those copies instead, so the branch is created there. Verified: `git branch --list` is unchanged across the run (2 before, 2 after), 2 passed / 1 skipped on Windows (no native bash; the bash case runs on Linux CI). Assisted-by: DeepSeek Harness (model: deepseek-v4.1-flash, autonomous) --- tests/test_git_fetch_prompt_guard.py | 47 ++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/tests/test_git_fetch_prompt_guard.py b/tests/test_git_fetch_prompt_guard.py index 28f19be5eb..03dd9b0791 100644 --- a/tests/test_git_fetch_prompt_guard.py +++ b/tests/test_git_fetch_prompt_guard.py @@ -78,9 +78,46 @@ def fake_run(cmd, **kwargs): assert fetch_calls[0][1]["env"]["GIT_TERMINAL_PROMPT"] == "0" +def _install_scripts(repo: Path) -> Path: + """Install the extension scripts inside the temp project. + + The scripts resolve their project root from their own location, so running + the checkout's copy would make them create a branch in this repository + instead of the temp project. + + Args: + repo: Temporary project root. + + Returns: + The temp project root, with ``.specify/scripts`` populated. + """ + layout = { + "bash": ( + PROJECT_ROOT / "scripts" / "bash" / "common.sh", + EXT_SCRIPTS / "bash" / "git-common.sh", + EXT_SCRIPTS / "bash" / "create-new-feature-branch.sh", + ), + "powershell": ( + PROJECT_ROOT / "scripts" / "powershell" / "common.ps1", + EXT_SCRIPTS / "powershell" / "git-common.ps1", + EXT_SCRIPTS / "powershell" / "create-new-feature-branch.ps1", + ), + "python": (PROJECT_ROOT / "scripts" / "python" / "common.py",), + } + for flavor, sources in layout.items(): + target = repo / ".specify" / "scripts" / flavor + target.mkdir(parents=True) + for source in sources: + if source.is_file(): + shutil.copy(source, target / source.name) + return repo + + @requires_bash def test_bash_fetch_disables_terminal_prompt(tmp_path): _init_repo(tmp_path) + _install_scripts(tmp_path) + script = tmp_path / ".specify" / "scripts" / "bash" / "create-new-feature-branch.sh" log = tmp_path / "prompt.log" harness = tmp_path / "harness.sh" harness.write_text( @@ -93,11 +130,11 @@ def test_bash_fetch_disables_terminal_prompt(tmp_path): ' command git "$@"\n' "}\n" "export -f git\n" - f'exec bash "{BASH_SCRIPT.as_posix()}" --short-name probe-fetch-guard "fetch guard"\n', + f'exec bash "{script.as_posix()}" --short-name probe-fetch-guard "fetch guard"\n', encoding="utf-8", ) - _run(["bash", str(harness)], cwd=tmp_path, log=log) + _run(["bash", harness.as_posix()], cwd=tmp_path, log=log) assert log.read_text(encoding="utf-8") == "0" @@ -105,6 +142,10 @@ def test_bash_fetch_disables_terminal_prompt(tmp_path): @pytest.mark.skipif(not HAS_PWSH, reason="pwsh is not available") def test_powershell_fetch_disables_terminal_prompt(tmp_path): _init_repo(tmp_path) + _install_scripts(tmp_path) + script = ( + tmp_path / ".specify" / "scripts" / "powershell" / "create-new-feature-branch.ps1" + ) log = tmp_path / "prompt.log" harness = tmp_path / "harness.ps1" harness.write_text( @@ -116,7 +157,7 @@ def test_powershell_fetch_disables_terminal_prompt(tmp_path): " $real = (Get-Command git.exe -CommandType Application).Source\n" " & $real @args\n" "}\n" - f". '{PS1_SCRIPT}' -ShortName 'probe-fetch-guard' 'fetch guard'\n", + f". '{script}' -ShortName 'probe-fetch-guard' 'fetch guard'\n", encoding="utf-8", )