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/git/scripts/bash/create-new-feature-branch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

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

Expand Down
110 changes: 110 additions & 0 deletions tests/extensions/git/test_git_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions tests/extensions/git/test_git_extension_python_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down