Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion extensions/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion extensions/git/extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion extensions/git/scripts/bash/create-new-feature-branch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 4 additions & 0 deletions extensions/git/scripts/python/create_new_feature_branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
166 changes: 166 additions & 0 deletions tests/test_git_fetch_prompt_guard.py
Original file line number Diff line number Diff line change
@@ -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-<unset>}" > "$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"
Loading