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/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..03dd9b0791 --- /dev/null +++ b/tests/test_git_fetch_prompt_guard.py @@ -0,0 +1,166 @@ +"""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" + + +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( + "#!/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 "{script.as_posix()}" --short-name probe-fetch-guard "fetch guard"\n', + encoding="utf-8", + ) + + _run(["bash", harness.as_posix()], 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) + _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( + "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". '{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"