Files
github-spec-kit/tests/test_timestamp_branches.py
Ali jawwad a473955e3e fix(scripts): warn when spec template is missing in create-new-feature.ps1 (parity with bash) (#3230)
* fix(scripts): warn when spec template is missing in create-new-feature.ps1 (parity with bash)

create-new-feature.sh prints 'Warning: Spec template not found; created empty spec file' to stderr when no spec template resolves, then touches an empty spec. The PowerShell twin created the empty file silently with no warning, so on Windows a missing/broken template tree gave no signal. Emit the same warning on stderr (keeps stdout/JSON pure), matching the bash wording and stream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: assert create-new-feature.ps1 warns on missing spec template

Regression test for the bash/PowerShell parity fix: with no resolvable spec template, the PowerShell script must emit 'Spec template not found' on stderr (matching bash) while keeping stdout parseable JSON and still creating the empty spec file. Gated on pwsh; decodes stdout/stderr as UTF-8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:46:35 -05:00

1324 lines
57 KiB
Python

"""
Pytest tests for timestamp-based branch naming in create-new-feature.sh and common.sh.
Converted from tests/test_timestamp_branches.sh so they are discovered by `uv run pytest`.
"""
import json
import os
import re
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.conftest import requires_bash
PROJECT_ROOT = Path(__file__).resolve().parent.parent
CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh"
CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1"
EXT_CREATE_FEATURE = (
PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature-branch.sh"
)
EXT_CREATE_FEATURE_PS = (
PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
)
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
HAS_PWSH = shutil.which("pwsh") is not None
def _has_pwsh() -> bool:
"""Check if pwsh is available."""
return HAS_PWSH
@pytest.fixture
def git_repo(tmp_path: Path) -> Path:
"""Create a temp git repo with scripts and .specify dir."""
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True
)
subprocess.run(
["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True
)
subprocess.run(
["git", "commit", "--allow-empty", "-m", "init", "-q"],
cwd=tmp_path,
check=True,
)
scripts_dir = tmp_path / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
shutil.copy(CREATE_FEATURE, scripts_dir / "create-new-feature.sh")
shutil.copy(COMMON_SH, scripts_dir / "common.sh")
(tmp_path / ".specify" / "templates").mkdir(parents=True)
return tmp_path
@pytest.fixture
def ext_git_repo(tmp_path: Path) -> Path:
"""Create a temp git repo with extension scripts (for GIT_BRANCH_NAME tests)."""
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True)
subprocess.run(["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=tmp_path, check=True)
# Extension script needs common.sh at .specify/scripts/bash/
specify_scripts = tmp_path / ".specify" / "scripts" / "bash"
specify_scripts.mkdir(parents=True)
shutil.copy(COMMON_SH, specify_scripts / "common.sh")
# Also install core scripts for compatibility
core_scripts = tmp_path / "scripts" / "bash"
core_scripts.mkdir(parents=True)
shutil.copy(COMMON_SH, core_scripts / "common.sh")
# Copy extension script
ext_dir = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash"
ext_dir.mkdir(parents=True)
shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature-branch.sh")
# Also copy git-common.sh if it exists
git_common = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
if git_common.exists():
shutil.copy(git_common, ext_dir / "git-common.sh")
(tmp_path / ".specify" / "templates").mkdir(parents=True, exist_ok=True)
(tmp_path / "specs").mkdir(exist_ok=True)
return tmp_path
@pytest.fixture
def ext_ps_git_repo(tmp_path: Path) -> Path:
"""Create a temp git repo with PowerShell extension scripts."""
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True)
subprocess.run(["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=tmp_path, check=True)
# Install core PS scripts
ps_dir = tmp_path / "scripts" / "powershell"
ps_dir.mkdir(parents=True)
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
shutil.copy(common_ps, ps_dir / "common.ps1")
# Also install at .specify/scripts/powershell/ for extension resolution
specify_ps = tmp_path / ".specify" / "scripts" / "powershell"
specify_ps.mkdir(parents=True)
shutil.copy(common_ps, specify_ps / "common.ps1")
# Copy extension script
ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell"
ext_ps.mkdir(parents=True)
shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature-branch.ps1")
git_common_ps = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
if git_common_ps.exists():
shutil.copy(git_common_ps, ext_ps / "git-common.ps1")
(tmp_path / ".specify" / "templates").mkdir(parents=True, exist_ok=True)
(tmp_path / "specs").mkdir(exist_ok=True)
return tmp_path
@pytest.fixture
def ps_git_repo(tmp_path: Path) -> Path:
"""Create a temp git repo with PowerShell scripts and a BOM-prefixed template."""
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True
)
subprocess.run(
["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True
)
subprocess.run(
["git", "commit", "--allow-empty", "-m", "init", "-q"],
cwd=tmp_path,
check=True,
)
ps_dir = tmp_path / "scripts" / "powershell"
ps_dir.mkdir(parents=True)
shutil.copy(CREATE_FEATURE_PS, ps_dir / "create-new-feature.ps1")
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
shutil.copy(common_ps, ps_dir / "common.ps1")
templates_dir = tmp_path / ".specify" / "templates"
templates_dir.mkdir(parents=True)
# Write a BOM-prefixed template to ensure the WriteAllText fix is actually exercised.
# If WriteAllText regresses, the output file will contain the BOM.
bom = b"\xef\xbb\xbf"
template_content = "# Feature Spec\n\nDescribe the feature here.\n"
(templates_dir / "spec-template.md").write_bytes(bom + template_content.encode("utf-8"))
return tmp_path
@pytest.fixture
def no_git_dir(tmp_path: Path) -> Path:
"""Create a temp directory without git, but with scripts."""
scripts_dir = tmp_path / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
shutil.copy(CREATE_FEATURE, scripts_dir / "create-new-feature.sh")
shutil.copy(COMMON_SH, scripts_dir / "common.sh")
(tmp_path / ".specify" / "templates").mkdir(parents=True)
return tmp_path
def run_script(cwd: Path, *args: str) -> subprocess.CompletedProcess:
"""Run create-new-feature.sh with given args."""
cmd = ["bash", "scripts/bash/create-new-feature.sh", *args]
return subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
)
def source_and_call(func_call: str, env: dict | None = None) -> subprocess.CompletedProcess:
"""Source common.sh and call a function."""
cmd = f'source "{COMMON_SH}" && {func_call}'
return subprocess.run(
["bash", "-c", cmd],
capture_output=True,
text=True,
env={**os.environ, **(env or {})},
)
# ── Timestamp Branch Tests ───────────────────────────────────────────────────
@requires_bash
class TestTimestampBranch:
def test_timestamp_creates_branch(self, git_repo: Path):
"""Test 1: --timestamp creates branch with YYYYMMDD-HHMMSS prefix."""
result = run_script(git_repo, "--timestamp", "--short-name", "user-auth", "Add user auth")
assert result.returncode == 0, result.stderr
branch = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch = line.split(":", 1)[1].strip()
assert branch is not None
assert re.match(r"^\d{8}-\d{6}-user-auth$", branch), f"unexpected branch: {branch}"
def test_number_and_timestamp_warns(self, git_repo: Path):
"""Test 3: --number + --timestamp warns and uses timestamp."""
result = run_script(git_repo, "--timestamp", "--number", "42", "--short-name", "feat", "Feature")
assert result.returncode == 0, result.stderr
assert "Warning" in result.stderr and "--number" in result.stderr
def test_json_output_keys(self, git_repo: Path):
"""Test 4: JSON output contains expected keys."""
import json
result = run_script(git_repo, "--json", "--timestamp", "--short-name", "api", "API feature")
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
for key in ("BRANCH_NAME", "SPEC_FILE", "FEATURE_NUM"):
assert key in data, f"missing {key} in JSON: {data}"
assert re.match(r"^\d{8}-\d{6}$", data["FEATURE_NUM"])
def test_long_name_truncation(self, git_repo: Path):
"""Test 5: Long branch name is truncated to <= 244 chars."""
long_name = "a-" * 150 + "end"
result = run_script(git_repo, "--timestamp", "--short-name", long_name, "Long feature")
assert result.returncode == 0, result.stderr
branch = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch = line.split(":", 1)[1].strip()
assert branch is not None
assert len(branch) <= 244
assert re.match(r"^\d{8}-\d{6}-", branch)
# ── Sequential Branch Tests ──────────────────────────────────────────────────
@requires_bash
class TestSequentialBranch:
def test_sequential_default_with_existing_specs(self, git_repo: Path):
"""Test 2: Sequential default with existing specs."""
(git_repo / "specs" / "001-first-feat").mkdir(parents=True)
(git_repo / "specs" / "002-second-feat").mkdir(parents=True)
result = run_script(git_repo, "--short-name", "new-feat", "New feature")
assert result.returncode == 0, result.stderr
branch = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch = line.split(":", 1)[1].strip()
assert branch is not None
assert re.match(r"^\d{3,}-new-feat$", branch), f"unexpected branch: {branch}"
def test_branch_name_short_word_case_sensitivity(self, git_repo: Path):
"""A short word is dropped from the derived branch name unless it appears
as an acronym in UPPERCASE in the description. The PowerShell twin must use
case-sensitive -cmatch to produce the same result."""
r1 = run_script(git_repo, "--json", "--dry-run", "Add go support")
assert r1.returncode == 0, r1.stderr
assert json.loads(r1.stdout)["BRANCH_NAME"] == "001-support"
r2 = run_script(git_repo, "--json", "--dry-run", "Use GO now")
assert r2.returncode == 0, r2.stderr
assert json.loads(r2.stdout)["BRANCH_NAME"] == "001-use-go-now"
def test_sequential_ignores_timestamp_dirs(self, git_repo: Path):
"""Sequential numbering skips timestamp dirs when computing next number."""
(git_repo / "specs" / "002-first-feat").mkdir(parents=True)
(git_repo / "specs" / "20260319-143022-ts-feat").mkdir(parents=True)
result = run_script(git_repo, "--short-name", "next-feat", "Next feature")
assert result.returncode == 0, result.stderr
branch = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch = line.split(":", 1)[1].strip()
assert branch == "003-next-feat", f"expected 003-next-feat, got: {branch}"
def test_sequential_supports_four_digit_prefixes(self, git_repo: Path):
"""Sequential numbering should continue past 999 without truncation."""
(git_repo / "specs" / "999-last-3digit").mkdir(parents=True)
(git_repo / "specs" / "1000-first-4digit").mkdir(parents=True)
result = run_script(git_repo, "--short-name", "next-feat", "Next feature")
assert result.returncode == 0, result.stderr
branch = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch = line.split(":", 1)[1].strip()
assert branch == "1001-next-feat", f"expected 1001-next-feat, got: {branch}"
def test_explicit_number_zero_is_honored(self, git_repo: Path):
"""An explicit --number 0 is honored literally (FEATURE_NUM 000), not treated
as auto-detect, even when higher-numbered specs already exist. This pins the
canonical bash behavior the PowerShell twin must mirror."""
(git_repo / "specs" / "003-existing").mkdir(parents=True)
r = run_script(
git_repo, "--json", "--dry-run", "--number", "0", "--short-name", "zero", "Zero feature",
)
assert r.returncode == 0, r.stderr
data = json.loads(r.stdout)
assert data["FEATURE_NUM"] == "000"
assert data["BRANCH_NAME"] == "000-zero"
class TestSequentialBranchPowerShell:
def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self):
"""PowerShell scanner should parse large prefixes without [int] casts."""
content = CREATE_FEATURE_PS.read_text(encoding="utf-8")
assert "[long]::TryParse($matches[1], [ref]$num)" in content
assert "$num = [int]$matches[1]" not in content
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_branch_name_short_word_case_sensitivity(self, ps_git_repo: Path):
"""Core create-new-feature.ps1 must drop a short word unless it appears as
an acronym in UPPERCASE (case-sensitive -cmatch), matching the bash twin."""
script = ps_git_repo / "scripts" / "powershell" / "create-new-feature.ps1"
def _run(desc: str) -> subprocess.CompletedProcess:
return subprocess.run(
["pwsh", "-NoProfile", "-File", str(script), "-Json", "-DryRun", desc],
cwd=ps_git_repo, capture_output=True, text=True,
)
r1 = _run("Add go support")
assert r1.returncode == 0, r1.stderr
assert json.loads(r1.stdout)["BRANCH_NAME"] == "001-support"
r2 = _run("Use GO now")
assert r2.returncode == 0, r2.stderr
assert json.loads(r2.stdout)["BRANCH_NAME"] == "001-use-go-now"
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_explicit_number_zero_is_honored_matching_bash(self, ps_git_repo: Path):
"""An explicit -Number 0 must be honored (FEATURE_NUM 000) like the bash twin,
even when higher-numbered specs exist. Before the fix, PowerShell could not
distinguish -Number 0 from the default and silently auto-detected (e.g. 004)."""
script = ps_git_repo / "scripts" / "powershell" / "create-new-feature.ps1"
(ps_git_repo / "specs" / "003-existing").mkdir(parents=True)
result = subprocess.run(
["pwsh", "-NoProfile", "-File", str(script),
"-Json", "-DryRun", "-Number", "0", "-ShortName", "zero", "Zero feature"],
cwd=ps_git_repo, capture_output=True, text=True,
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["FEATURE_NUM"] == "000"
assert data["BRANCH_NAME"] == "000-zero"
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_missing_spec_template_warns_matching_bash(self, ps_git_repo: Path):
"""When no spec template can be resolved, create-new-feature.ps1 must warn on
stderr (and still create an empty spec file), matching the bash twin's
'Warning: Spec template not found; created empty spec file'. Before the fix
PowerShell created the empty file silently."""
# Remove the template the fixture installs so resolution finds nothing.
(ps_git_repo / ".specify" / "templates" / "spec-template.md").unlink()
script = ps_git_repo / "scripts" / "powershell" / "create-new-feature.ps1"
result = subprocess.run(
["pwsh", "-NoProfile", "-File", str(script),
"-Json", "-ShortName", "no-tmpl", "No template feature"],
cwd=ps_git_repo, capture_output=True, text=True, encoding="utf-8",
)
assert result.returncode == 0, result.stderr
assert "Spec template not found" in result.stderr
# stdout stays parseable JSON and the empty spec file is still created.
data = json.loads(result.stdout)
spec_file = Path(data["SPEC_FILE"])
assert spec_file.is_file()
# ── check_feature_branch Tests ───────────────────────────────────────────────
@requires_bash
class TestCoreCommonRemovesGitHelpers:
def test_check_feature_branch_removed(self):
result = source_and_call('declare -F check_feature_branch >/dev/null')
assert result.returncode != 0
def test_has_git_removed(self):
result = source_and_call('declare -F has_git >/dev/null')
assert result.returncode != 0
# ── find_feature_dir_by_prefix Tests ─────────────────────────────────────────
@requires_bash
class TestFindFeatureDirByPrefixRemoved:
def test_find_feature_dir_by_prefix_removed(self):
"""Directory scanning helper is removed from core common.sh."""
result = source_and_call('declare -F find_feature_dir_by_prefix >/dev/null')
assert result.returncode != 0
# ── get_feature_paths + single-prefix integration ───────────────────────────
class TestGetFeaturePathsSinglePrefix:
@requires_bash
def test_bash_specify_feature_prefixed_requires_explicit_feature_context(
self, tmp_path: Path
):
"""SPECIFY_FEATURE alone no longer triggers path lookup in bash."""
(tmp_path / ".specify").mkdir()
(tmp_path / "specs" / "001-target-spec").mkdir(parents=True)
cmd = (
f'cd "{tmp_path}" && export SPECIFY_FEATURE="feat/001-other" && '
f'source "{COMMON_SH}" && get_feature_paths'
)
result = subprocess.run(
["bash", "-c", cmd],
capture_output=True,
text=True,
)
assert result.returncode != 0
assert "Feature directory not found" in result.stderr
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_ps_specify_feature_prefixed_requires_explicit_feature_context(
self, git_repo: Path
):
"""PowerShell also requires feature.json or SPECIFY_FEATURE_DIRECTORY."""
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
spec_dir = git_repo / "specs" / "001-ps-prefix-spec"
spec_dir.mkdir(parents=True)
ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"'
result = subprocess.run(
["pwsh", "-NoProfile", "-Command", ps_cmd],
cwd=git_repo,
capture_output=True,
text=True,
env={**os.environ, "SPECIFY_FEATURE": "feat/001-other"},
)
assert result.returncode != 0
assert "Feature directory not found" in (result.stderr + result.stdout)
# ── get_current_branch Tests ─────────────────────────────────────────────────
@requires_bash
class TestGetCurrentBranch:
def test_env_var(self):
"""Test 12: get_current_branch returns SPECIFY_FEATURE env var."""
result = source_and_call("get_current_branch", env={"SPECIFY_FEATURE": "my-custom-branch"})
assert result.stdout.strip() == "my-custom-branch"
# ── No-git Tests ─────────────────────────────────────────────────────────────
@requires_bash
class TestNoGitTimestamp:
def test_no_git_timestamp(self, no_git_dir: Path):
"""Test 13: Timestamp mode works without git and creates a spec dir."""
result = run_script(no_git_dir, "--timestamp", "--short-name", "no-git-feat", "No git feature")
assert result.returncode == 0, result.stderr
spec_dirs = list((no_git_dir / "specs").iterdir()) if (no_git_dir / "specs").exists() else []
assert len(spec_dirs) > 0, "spec dir not created"
# ── E2E Flow Tests ───────────────────────────────────────────────────────────
@requires_bash
class TestE2EFlow:
def test_e2e_timestamp(self, git_repo: Path):
"""Test 14: E2E timestamp flow creates only a feature directory."""
before = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
result = run_script(git_repo, "--timestamp", "--short-name", "e2e-ts", "E2E timestamp test")
assert result.returncode == 0, result.stderr
branch_name = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch_name = line.split(":", 1)[1].strip()
break
assert branch_name is not None
assert re.match(r"^\d{8}-\d{6}-e2e-ts$", branch_name), f"branch: {branch_name}"
assert (git_repo / "specs" / branch_name).is_dir()
after = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert after == before
def test_e2e_sequential(self, git_repo: Path):
"""Test 15: E2E sequential flow creates only a feature directory."""
before = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
result = run_script(git_repo, "--short-name", "seq-feat", "Sequential feature")
assert result.returncode == 0, result.stderr
branch_name = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch_name = line.split(":", 1)[1].strip()
break
assert branch_name == "001-seq-feat"
assert (git_repo / "specs" / branch_name).is_dir()
after = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert after == before
# ── Allow Existing Branch Tests ──────────────────────────────────────────────
@requires_bash
class TestAllowExistingBranch:
def test_allow_existing_reuses_existing_feature_dir(self, git_repo: Path):
"""T006: Existing feature directory can be reused when the flag is set."""
feature_dir = git_repo / "specs" / "004-pre-exist"
feature_dir.mkdir(parents=True)
result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "pre-exist",
"--number", "4", "Pre-existing feature",
)
assert result.returncode == 0, result.stderr
assert feature_dir.is_dir()
assert (feature_dir / "spec.md").exists()
def test_without_flag_still_errors(self, git_repo: Path):
"""T009: Existing feature directories still fail without the flag."""
(git_repo / "specs" / "007-no-flag").mkdir(parents=True)
result = run_script(
git_repo, "--short-name", "no-flag", "--number", "7", "No flag feature",
)
assert result.returncode != 0, "should fail without --allow-existing-branch"
assert "already exists" in result.stderr
def test_allow_existing_no_overwrite_spec(self, git_repo: Path):
"""T010: Pre-create spec.md with content, verify it is preserved."""
spec_dir = git_repo / "specs" / "008-no-overwrite"
spec_dir.mkdir(parents=True)
spec_file = spec_dir / "spec.md"
spec_file.write_text("# My custom spec content\n")
result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "no-overwrite",
"--number", "8", "No overwrite feature",
)
assert result.returncode == 0, result.stderr
assert spec_file.read_text() == "# My custom spec content\n"
def test_allow_existing_creates_feature_dir_when_missing(self, git_repo: Path):
"""T011: Verify normal directory creation when the feature dir does not exist."""
result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "new-branch",
"New branch feature",
)
assert result.returncode == 0, result.stderr
assert (git_repo / "specs" / "001-new-branch").is_dir()
def test_allow_existing_with_json(self, git_repo: Path):
"""T012: Verify JSON output is correct."""
import json
(git_repo / "specs" / "009-json-test").mkdir(parents=True)
result = run_script(
git_repo, "--allow-existing-branch", "--json", "--short-name", "json-test",
"--number", "9", "JSON test",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "009-json-test"
def test_allow_existing_no_git(self, no_git_dir: Path):
"""T013: Verify flag also works in non-git repos."""
result = run_script(
no_git_dir, "--allow-existing-branch", "--short-name", "no-git",
"No git feature",
)
assert result.returncode == 0, result.stderr
class TestAllowExistingBranchPowerShell:
def test_powershell_supports_allow_existing_branch_flag(self):
"""Static guard: PS script exposes and uses -AllowExistingBranch."""
contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
assert "-AllowExistingBranch" in contents
assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "")
def test_powershell_reuses_existing_feature_dir(self):
"""Static guard: PS script handles existing feature directories without git."""
contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
assert "Feature directory '$featureDir' already exists" in contents
assert "-not $AllowExistingBranch" in contents
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
@pytest.mark.skipif(
os.name != "nt" or shutil.which("powershell.exe") is None,
reason="Windows PowerShell not installed",
)
def test_ps_spec_file_written_without_bom(self, ps_git_repo: Path):
"""spec.md generated from a BOM-prefixed template must not contain a UTF-8 BOM."""
result = subprocess.run(
[
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(CREATE_FEATURE_PS),
"-ShortName",
"bom-check",
"BOM check feature",
],
cwd=ps_git_repo,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
spec_file = next((ps_git_repo / "specs").rglob("spec.md"), None)
assert spec_file is not None, (
f"spec.md was not created.\nstdout: {result.stdout}\nstderr: {result.stderr}"
)
raw = spec_file.read_bytes()
assert not raw.startswith(b"\xef\xbb\xbf"), (
f"spec.md must not start with a UTF-8 BOM — got first 3 bytes: {raw[:3]!r}"
)
# Verify template content was copied (not just an empty New-Item fallback)
assert "Feature Spec" in raw.decode("utf-8"), (
"spec.md does not contain template content — WriteAllText path was not exercised"
)
class TestGitExtensionParity:
def test_bash_extension_surfaces_checkout_errors(self):
"""Static guard: git extension bash script preserves checkout stderr."""
contents = EXT_CREATE_FEATURE.read_text(encoding="utf-8")
assert 'switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1)' in contents
assert "Failed to switch to existing branch '$BRANCH_NAME'" in contents
def test_powershell_extension_surfaces_checkout_errors(self):
"""Static guard: git extension PowerShell script preserves checkout stderr."""
contents = EXT_CREATE_FEATURE_PS.read_text(encoding="utf-8")
assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents
assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents
# ── Dry-Run Tests ────────────────────────────────────────────────────────────
@requires_bash
class TestDryRun:
def test_dry_run_sequential_outputs_name(self, git_repo: Path):
"""T009: Dry-run computes correct branch name with existing specs."""
(git_repo / "specs" / "001-first-feat").mkdir(parents=True)
(git_repo / "specs" / "002-second-feat").mkdir(parents=True)
result = run_script(
git_repo, "--dry-run", "--short-name", "new-feat", "New feature"
)
assert result.returncode == 0, result.stderr
branch = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch = line.split(":", 1)[1].strip()
assert branch == "003-new-feat", f"expected 003-new-feat, got: {branch}"
def test_dry_run_does_not_change_git_branch(self, git_repo: Path):
"""T010: Dry-run leaves the current git branch untouched."""
before = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
result = run_script(
git_repo, "--dry-run", "--short-name", "no-branch", "No branch feature"
)
assert result.returncode == 0, result.stderr
after = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert after == before
def test_dry_run_no_spec_dir_created(self, git_repo: Path):
"""T011: Dry-run does not create any directories (including root specs/)."""
specs_root = git_repo / "specs"
if specs_root.exists():
shutil.rmtree(specs_root)
assert not specs_root.exists(), "specs/ should not exist before dry-run"
result = run_script(
git_repo, "--dry-run", "--short-name", "no-dir", "No dir feature"
)
assert result.returncode == 0, result.stderr
assert not specs_root.exists(), "specs/ should not be created during dry-run"
def test_dry_run_empty_repo(self, git_repo: Path):
"""T012: Dry-run returns 001 prefix when no existing specs or branches."""
result = run_script(
git_repo, "--dry-run", "--short-name", "first", "First feature"
)
assert result.returncode == 0, result.stderr
branch = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch = line.split(":", 1)[1].strip()
assert branch == "001-first", f"expected 001-first, got: {branch}"
def test_dry_run_with_short_name(self, git_repo: Path):
"""T013: Dry-run with --short-name produces expected name."""
(git_repo / "specs" / "001-existing").mkdir(parents=True)
(git_repo / "specs" / "002-existing").mkdir(parents=True)
(git_repo / "specs" / "003-existing").mkdir(parents=True)
result = run_script(
git_repo, "--dry-run", "--short-name", "user-auth", "Add user authentication"
)
assert result.returncode == 0, result.stderr
branch = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch = line.split(":", 1)[1].strip()
assert branch == "004-user-auth", f"expected 004-user-auth, got: {branch}"
def test_dry_run_then_real_run_match(self, git_repo: Path):
"""T014: Dry-run name matches subsequent real creation."""
(git_repo / "specs" / "001-existing").mkdir(parents=True)
# Dry-run first
dry_result = run_script(
git_repo, "--dry-run", "--short-name", "match-test", "Match test"
)
assert dry_result.returncode == 0, dry_result.stderr
dry_branch = None
for line in dry_result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
dry_branch = line.split(":", 1)[1].strip()
# Real run
real_result = run_script(
git_repo, "--short-name", "match-test", "Match test"
)
assert real_result.returncode == 0, real_result.stderr
real_branch = None
for line in real_result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
real_branch = line.split(":", 1)[1].strip()
assert dry_branch == real_branch, f"dry={dry_branch} != real={real_branch}"
def test_dry_run_ignores_git_branches(self, git_repo: Path):
"""Dry-run uses only spec directories for numbering."""
(git_repo / "specs" / "001-existing").mkdir(parents=True)
subprocess.run(
["git", "checkout", "-b", "005-git-only"],
cwd=git_repo,
check=True,
capture_output=True,
)
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo,
check=True,
capture_output=True,
)
dry_result = run_script(
git_repo, "--dry-run", "--short-name", "remote-test", "Remote test"
)
assert dry_result.returncode == 0, dry_result.stderr
dry_branch = None
for line in dry_result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
dry_branch = line.split(":", 1)[1].strip()
assert dry_branch == "002-remote-test", f"expected 002-remote-test, got: {dry_branch}"
def test_dry_run_json_includes_field(self, git_repo: Path):
"""T015: JSON output includes DRY_RUN field when --dry-run is active."""
import json
result = run_script(
git_repo, "--dry-run", "--json", "--short-name", "json-test", "JSON test"
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "DRY_RUN" in data, f"DRY_RUN missing from JSON: {data}"
assert data["DRY_RUN"] is True
def test_dry_run_json_absent_without_flag(self, git_repo: Path):
"""T016: Normal JSON output does NOT include DRY_RUN field."""
import json
result = run_script(
git_repo, "--json", "--short-name", "no-dry", "No dry run"
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}"
def test_dry_run_with_timestamp(self, git_repo: Path):
"""T017: Dry-run works with --timestamp flag without mutating git state."""
before = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
result = run_script(
git_repo, "--dry-run", "--timestamp", "--short-name", "ts-feat", "Timestamp feature"
)
assert result.returncode == 0, result.stderr
branch = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch = line.split(":", 1)[1].strip()
assert branch is not None, "no BRANCH_NAME in output"
assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}"
after = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert after == before
def test_dry_run_with_number(self, git_repo: Path):
"""T018: Dry-run works with --number flag."""
result = run_script(
git_repo, "--dry-run", "--number", "42", "--short-name", "num-feat", "Number feature"
)
assert result.returncode == 0, result.stderr
branch = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch = line.split(":", 1)[1].strip()
assert branch == "042-num-feat", f"expected 042-num-feat, got: {branch}"
def test_dry_run_no_git(self, no_git_dir: Path):
"""T019: Dry-run works in non-git directory."""
(no_git_dir / "specs" / "001-existing").mkdir(parents=True)
result = run_script(
no_git_dir, "--dry-run", "--short-name", "no-git-dry", "No git dry run"
)
assert result.returncode == 0, result.stderr
branch = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch = line.split(":", 1)[1].strip()
assert branch == "002-no-git-dry", f"expected 002-no-git-dry, got: {branch}"
# Verify no spec dir created
spec_dirs = [
d.name
for d in (no_git_dir / "specs").iterdir()
if d.is_dir() and "no-git-dry" in d.name
]
assert len(spec_dirs) == 0
# ── PowerShell Dry-Run Tests ─────────────────────────────────────────────────
def run_ps_script(cwd: Path, *args: str) -> subprocess.CompletedProcess:
"""Run create-new-feature.ps1 from the temp repo's scripts directory."""
script = cwd / "scripts" / "powershell" / "create-new-feature.ps1"
cmd = ["pwsh", "-NoProfile", "-File", str(script), *args]
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not available")
class TestPowerShellDryRun:
def test_ps_dry_run_outputs_name(self, ps_git_repo: Path):
"""PowerShell -DryRun computes correct branch name."""
(ps_git_repo / "specs" / "001-first").mkdir(parents=True)
result = run_ps_script(
ps_git_repo, "-DryRun", "-ShortName", "ps-feat", "PS feature"
)
assert result.returncode == 0, result.stderr
branch = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch = line.split(":", 1)[1].strip()
assert branch == "002-ps-feat", f"expected 002-ps-feat, got: {branch}"
def test_ps_dry_run_does_not_change_git_branch(self, ps_git_repo: Path):
"""PowerShell -DryRun leaves the current git branch untouched."""
before = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=ps_git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
result = run_ps_script(
ps_git_repo, "-DryRun", "-ShortName", "no-ps-branch", "No branch"
)
assert result.returncode == 0, result.stderr
after = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=ps_git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert after == before
def test_ps_dry_run_no_spec_dir_created(self, ps_git_repo: Path):
"""PowerShell -DryRun does not create specs/ directory."""
specs_root = ps_git_repo / "specs"
if specs_root.exists():
shutil.rmtree(specs_root)
assert not specs_root.exists()
result = run_ps_script(
ps_git_repo, "-DryRun", "-ShortName", "no-ps-dir", "No dir"
)
assert result.returncode == 0, result.stderr
assert not specs_root.exists(), "specs/ should not be created during dry-run"
def test_ps_dry_run_json_includes_field(self, ps_git_repo: Path):
"""PowerShell -DryRun JSON output includes DRY_RUN field."""
import json
result = run_ps_script(
ps_git_repo, "-DryRun", "-Json", "-ShortName", "ps-json", "JSON test"
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "DRY_RUN" in data, f"DRY_RUN missing from JSON: {data}"
assert data["DRY_RUN"] is True
def test_ps_dry_run_json_absent_without_flag(self, ps_git_repo: Path):
"""PowerShell normal JSON output does NOT include DRY_RUN field."""
import json
result = run_ps_script(
ps_git_repo, "-Json", "-ShortName", "ps-no-dry", "No dry run"
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}"
# ── Short-Word / Acronym Branch-Name Tests ──────────────────────────────────
def _branch_from_output(stdout: str) -> str | None:
for line in stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
return line.split(":", 1)[1].strip()
return None
SHORT_WORD_CASES = [
# description, expected branch — "go" (lowercase short word) is dropped,
# "AI" (uppercase short word / acronym) is kept, "now" (>=3 chars) is kept.
("go AI now", "001-ai-now"),
# A short word that is lowercase everywhere is dropped entirely.
("go to the pub", "001-pub"),
]
@requires_bash
class TestShortWordRetentionBash:
"""A short word is kept only when it appears in uppercase (an acronym)."""
@pytest.mark.parametrize("description,expected", SHORT_WORD_CASES)
def test_short_word_retention(self, git_repo: Path, description: str, expected: str):
result = run_script(git_repo, "--dry-run", description)
assert result.returncode == 0, result.stderr
assert _branch_from_output(result.stdout) == expected
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not available")
class TestShortWordRetentionPowerShell:
"""PowerShell must match bash: a short word is kept only when uppercase.
Regression guard for the `-match` (case-insensitive) vs `-cmatch`
(case-sensitive) divergence — with `-match`, every short non-stop word
leaked into the branch name even when it was lowercase.
"""
@pytest.mark.parametrize("description,expected", SHORT_WORD_CASES)
def test_short_word_retention(self, ps_git_repo: Path, description: str, expected: str):
result = run_ps_script(ps_git_repo, "-DryRun", description)
assert result.returncode == 0, result.stderr
assert _branch_from_output(result.stdout) == expected
# ── GIT_BRANCH_NAME Override Tests ──────────────────────────────────────────
@requires_bash
class TestGitBranchNameOverrideBash:
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature-branch.sh."""
def _run_ext(self, ext_git_repo: Path, env_extras: dict, *extra_args: str):
script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature-branch.sh"
cmd = ["bash", str(script), "--json", *extra_args, "ignored"]
return subprocess.run(cmd, cwd=ext_git_repo, capture_output=True, text=True,
env={**os.environ, **env_extras})
def test_exact_name_no_prefix(self, ext_git_repo: Path):
"""GIT_BRANCH_NAME is used verbatim with no numeric prefix added."""
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "my-exact-branch"})
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "my-exact-branch"
assert data["FEATURE_NUM"] == "my-exact-branch"
def test_sequential_prefix_extraction(self, ext_git_repo: Path):
"""FEATURE_NUM extracted from sequential-style prefix (digits before dash)."""
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "042-custom-branch"})
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "042-custom-branch"
assert data["FEATURE_NUM"] == "042"
def test_timestamp_prefix_extraction(self, ext_git_repo: Path):
"""FEATURE_NUM extracted as full YYYYMMDD-HHMMSS for timestamp-style names."""
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "20260407-143022-my-feature"})
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "20260407-143022-my-feature"
assert data["FEATURE_NUM"] == "20260407-143022"
def test_overlong_name_rejected(self, ext_git_repo: Path):
"""GIT_BRANCH_NAME exceeding 244 bytes is rejected with an error."""
long_name = "a" * 245
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": long_name})
assert result.returncode != 0
assert "244" in result.stderr
def test_dry_run_with_override(self, ext_git_repo: Path):
"""GIT_BRANCH_NAME works with --dry-run (no branch created)."""
result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "dry-run-override"}, "--dry-run")
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "dry-run-override"
assert data.get("DRY_RUN") is True
branches = subprocess.run(
["git", "branch", "--list", "dry-run-override"],
cwd=ext_git_repo, capture_output=True, text=True,
)
assert "dry-run-override" not in branches.stdout
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
class TestGitBranchNameOverridePowerShell:
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature-branch.ps1."""
def _run_ext(self, ext_ps_git_repo: Path, env_extras: dict):
script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
return subprocess.run(
["pwsh", "-NoProfile", "-File", str(script), "-Json", "ignored"],
cwd=ext_ps_git_repo, capture_output=True, text=True,
env={**os.environ, **env_extras},
)
def test_exact_name_no_prefix(self, ext_ps_git_repo: Path):
"""GIT_BRANCH_NAME is used verbatim with no numeric prefix added."""
result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "ps-exact-branch"})
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "ps-exact-branch"
assert data["FEATURE_NUM"] == "ps-exact-branch"
def test_sequential_prefix_extraction(self, ext_ps_git_repo: Path):
"""FEATURE_NUM extracted from sequential-style prefix."""
result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "099-ps-numbered"})
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "099-ps-numbered"
assert data["FEATURE_NUM"] == "099"
def test_timestamp_prefix_extraction(self, ext_ps_git_repo: Path):
"""FEATURE_NUM extracted as full YYYYMMDD-HHMMSS for timestamp-style names."""
result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "20260407-143022-ps-feature"})
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "20260407-143022-ps-feature"
assert data["FEATURE_NUM"] == "20260407-143022"
def test_overlong_name_rejected(self, ext_ps_git_repo: Path):
"""GIT_BRANCH_NAME exceeding 244 bytes is rejected."""
long_name = "a" * 245
result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": long_name})
assert result.returncode != 0
assert "244" in result.stderr
# ── Feature Directory Resolution Tests ───────────────────────────────────────
class TestFeatureDirectoryResolution:
"""Tests for SPECIFY_FEATURE_DIRECTORY and .specify/feature.json resolution."""
@requires_bash
def test_env_var_overrides_branch_lookup(self, git_repo: Path):
"""SPECIFY_FEATURE_DIRECTORY env var takes priority over branch-based lookup."""
custom_dir = git_repo / "my-custom-specs" / "my-feature"
custom_dir.mkdir(parents=True)
result = subprocess.run(
["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'],
cwd=git_repo,
capture_output=True,
text=True,
env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(custom_dir)},
)
assert result.returncode == 0, result.stderr
assert str(custom_dir) in result.stdout
for line in result.stdout.splitlines():
if line.startswith("FEATURE_DIR="):
val = line.split("=", 1)[1].strip("'\"")
assert val == str(custom_dir)
break
else:
pytest.fail("FEATURE_DIR not found in output")
@requires_bash
def test_feature_json_overrides_branch_lookup(self, git_repo: Path):
"""feature.json feature_directory takes priority over branch-based lookup."""
custom_dir = git_repo / "specs" / "custom-feature"
custom_dir.mkdir(parents=True)
feature_json = git_repo / ".specify" / "feature.json"
feature_json.write_text(
json.dumps({"feature_directory": str(custom_dir)}) + "\n",
encoding="utf-8",
)
result = subprocess.run(
["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'],
cwd=git_repo,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
for line in result.stdout.splitlines():
if line.startswith("FEATURE_DIR="):
val = line.split("=", 1)[1].strip("'\"")
assert val == str(custom_dir)
break
else:
pytest.fail("FEATURE_DIR not found in output")
@requires_bash
def test_env_var_takes_priority_over_feature_json(self, git_repo: Path):
"""Env var wins over feature.json."""
env_dir = git_repo / "specs" / "env-feature"
env_dir.mkdir(parents=True)
json_dir = git_repo / "specs" / "json-feature"
json_dir.mkdir(parents=True)
feature_json = git_repo / ".specify" / "feature.json"
feature_json.write_text(
json.dumps({"feature_directory": str(json_dir)}) + "\n",
encoding="utf-8",
)
result = subprocess.run(
["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'],
cwd=git_repo,
capture_output=True,
text=True,
env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(env_dir)},
)
assert result.returncode == 0, result.stderr
for line in result.stdout.splitlines():
if line.startswith("FEATURE_DIR="):
val = line.split("=", 1)[1].strip("'\"")
assert val == str(env_dir)
break
else:
pytest.fail("FEATURE_DIR not found in output")
@requires_bash
def test_errors_without_env_var_or_feature_json(self, git_repo: Path):
"""Without env var or feature.json, get_feature_paths now errors."""
spec_dir = git_repo / "specs" / "001-test-feat"
spec_dir.mkdir(parents=True)
result = subprocess.run(
["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'],
cwd=git_repo,
capture_output=True,
text=True,
)
assert result.returncode != 0
assert "Feature directory not found" in result.stderr
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_ps_env_var_overrides_branch_lookup(self, git_repo: Path):
"""PowerShell: SPECIFY_FEATURE_DIRECTORY env var takes priority."""
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
custom_dir = git_repo / "my-custom-specs" / "ps-feature"
custom_dir.mkdir(parents=True)
ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"'
result = subprocess.run(
["pwsh", "-NoProfile", "-Command", ps_cmd],
cwd=git_repo,
capture_output=True,
text=True,
env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(custom_dir)},
)
assert result.returncode == 0, result.stderr
for line in result.stdout.splitlines():
if line.startswith("FEATURE_DIR="):
val = line.split("=", 1)[1].strip("'\"")
assert val == str(custom_dir)
break
else:
pytest.fail("FEATURE_DIR not found in PowerShell output")
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_ps_feature_json_overrides_branch_lookup(self, git_repo: Path):
"""PowerShell: feature.json takes priority over branch-based lookup."""
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
custom_dir = git_repo / "specs" / "ps-json-feature"
custom_dir.mkdir(parents=True)
feature_json = git_repo / ".specify" / "feature.json"
feature_json.write_text(
json.dumps({"feature_directory": str(custom_dir)}) + "\n",
encoding="utf-8",
)
ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"'
result = subprocess.run(
["pwsh", "-NoProfile", "-Command", ps_cmd],
cwd=git_repo,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
for line in result.stdout.splitlines():
if line.startswith("FEATURE_DIR="):
val = line.split("=", 1)[1].strip("'\"")
assert val == str(custom_dir)
break
else:
pytest.fail("FEATURE_DIR not found in PowerShell output")
# ── Description Quoting Tests (issue #2339) ──────────────────────────────────
@requires_bash
class TestDescriptionQuoting:
"""Descriptions with quotes, apostrophes, and backslashes must not break the script.
Regression tests for https://github.com/github/spec-kit/issues/2339
"""
@pytest.mark.parametrize(
"description",
[
"Add user's profile page",
'Fix the "login" bug',
"Handle path\\with\\backslashes",
'It\'s a "complex" feature\\here',
],
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
)
def test_core_script_handles_special_chars(self, git_repo: Path, description: str):
"""Core create-new-feature.sh succeeds with special characters in description."""
result = run_script(git_repo, "--dry-run", "--short-name", "feat", description)
assert result.returncode == 0, (
f"Script failed for description {description!r}: {result.stderr}"
)
@pytest.mark.parametrize(
"description",
[
"Add user's profile page",
'Fix the "login" bug',
"Handle path\\with\\backslashes",
'It\'s a "complex" feature\\here',
],
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
)
def test_ext_script_handles_special_chars(self, ext_git_repo: Path, description: str):
"""Extension create-new-feature-branch.sh succeeds with special characters in description."""
script = (
ext_git_repo
/ ".specify"
/ "extensions"
/ "git"
/ "scripts"
/ "bash"
/ "create-new-feature-branch.sh"
)
result = subprocess.run(
["bash", str(script), "--dry-run", "--short-name", "feat", description],
cwd=ext_git_repo,
capture_output=True,
text=True,
)
assert result.returncode == 0, (
f"Script failed for description {description!r}: {result.stderr}"
)
def test_whitespace_only_still_rejected(self, git_repo: Path):
"""Whitespace-only descriptions must still be rejected after trimming."""
result = run_script(git_repo, "--dry-run", "--short-name", "feat", " ")
assert result.returncode != 0
assert "empty" in result.stderr.lower() or "whitespace" in result.stderr.lower()
def test_plain_description_still_works(self, git_repo: Path):
"""Plain description without special characters continues to work."""
result = run_script(git_repo, "--dry-run", "--short-name", "feat", "Add login feature")
assert result.returncode == 0, result.stderr