mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
* ci: add windows-latest to test matrix Add windows-latest to the pytest job OS matrix so tests run on both Ubuntu and Windows for all Python versions. Closes #2232 * test: skip bash-specific tests on Windows Add sys.platform skip markers to all test classes and methods that execute bash scripts via subprocess, so they are skipped on Windows where bash is not available. Mixed classes with both bash and pwsh tests have markers on individual bash methods only. * test: fix 3 Windows-specific test failures - test_manifest: use platform-appropriate absolute path (C:\ on Windows vs /tmp on POSIX) since /tmp is not absolute on Windows - test_extensions: add agent_scripts.ps entry and platform-conditional assertions for codex skill fallback variant test - test_timestamp_branches: use json.dumps() instead of f-string to properly escape Windows backslash paths in feature.json * test: extract requires_bash marker and fix PS test skip Address PR review feedback: - Define a reusable requires_bash marker in conftest.py and use it across all 3 test files instead of repeating the skipif inline - Move test_powershell_scanner_uses_long_tryparse_for_large_prefixes into its own TestSequentialBranchPowerShell class so it is not incorrectly skipped on Windows by the class-level bash marker * test: use runtime bash check instead of platform check Replace sys.platform == 'win32' with an actual bash invocation test to handle environments where bash exists but is non-functional (e.g., WSL stub on Windows without an installed distro). * test: reject WSL bash, accept only MSYS/MINGW on Windows On Windows, verify uname -s reports MSYS, MINGW, or CYGWIN so the WSL launcher (System32\bash.exe) is rejected — it cannot handle native Windows paths used by test fixtures. Add SPECKIT_TEST_BASH=1 env var escape hatch to force-enable bash tests in non-standard setups. * ci: add comment explaining Windows bash test behavior * test: early-reject WSL launcher, fix remaining f-string JSON - Check resolved bash path for System32 before spawning any subprocess to avoid WSL init prompts and timeout during test collection - Convert remaining feature_json f-string writes to json.dumps() so paths with backslashes produce valid JSON on Windows * test: use bare 'bash' for detection to match test invocation On Windows, subprocess.run(['bash', ...]) uses CreateProcess which searches System32 before PATH — finding WSL bash even when shutil.which('bash') returns Git-for-Windows. Probe with bare 'bash' (same as test helpers) so the detection matches actual test behavior.
1260 lines
56 KiB
Python
1260 lines
56 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.sh"
|
|
)
|
|
EXT_CREATE_FEATURE_PS = (
|
|
PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
|
|
)
|
|
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
|
EXT_CREATE_FEATURE = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
|
|
EXT_CREATE_FEATURE_PS = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
|
|
|
|
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.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.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 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_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}"
|
|
|
|
|
|
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
|
|
|
|
|
|
# ── check_feature_branch Tests ───────────────────────────────────────────────
|
|
|
|
|
|
@requires_bash
|
|
class TestCheckFeatureBranch:
|
|
def test_accepts_timestamp_branch(self):
|
|
"""Test 6: check_feature_branch accepts timestamp branch."""
|
|
result = source_and_call('check_feature_branch "20260319-143022-feat" "true"')
|
|
assert result.returncode == 0
|
|
|
|
def test_accepts_sequential_branch(self):
|
|
"""Test 7: check_feature_branch accepts sequential branch."""
|
|
result = source_and_call('check_feature_branch "004-feat" "true"')
|
|
assert result.returncode == 0
|
|
|
|
def test_rejects_main(self):
|
|
"""Test 8: check_feature_branch rejects main."""
|
|
result = source_and_call('check_feature_branch "main" "true"')
|
|
assert result.returncode != 0
|
|
|
|
def test_accepts_four_digit_sequential_branch(self):
|
|
"""check_feature_branch accepts 4+ digit sequential branch."""
|
|
result = source_and_call('check_feature_branch "1234-feat" "true"')
|
|
assert result.returncode == 0
|
|
|
|
def test_rejects_partial_timestamp(self):
|
|
"""Test 9: check_feature_branch rejects 7-digit date."""
|
|
result = source_and_call('check_feature_branch "2026031-143022-feat" "true"')
|
|
assert result.returncode != 0
|
|
|
|
def test_rejects_timestamp_without_slug(self):
|
|
"""check_feature_branch rejects timestamp-like branch missing trailing slug."""
|
|
result = source_and_call('check_feature_branch "20260319-143022" "true"')
|
|
assert result.returncode != 0
|
|
|
|
def test_rejects_7digit_timestamp_without_slug(self):
|
|
"""check_feature_branch rejects 7-digit date + 6-digit time without slug."""
|
|
result = source_and_call('check_feature_branch "2026031-143022" "true"')
|
|
assert result.returncode != 0
|
|
|
|
def test_accepts_single_prefix_sequential(self):
|
|
"""Optional gitflow-style prefix: one segment + sequential feature name."""
|
|
result = source_and_call('check_feature_branch "feat/004-my-feature" "true"')
|
|
assert result.returncode == 0
|
|
|
|
def test_accepts_single_prefix_timestamp(self):
|
|
"""Optional prefix + timestamp-style feature name."""
|
|
result = source_and_call('check_feature_branch "release/20260319-143022-feat" "true"')
|
|
assert result.returncode == 0
|
|
|
|
def test_rejects_invalid_suffix_with_single_prefix(self):
|
|
result = source_and_call('check_feature_branch "feat/main" "true"')
|
|
assert result.returncode != 0
|
|
assert "feat/main" in result.stderr
|
|
|
|
def test_rejects_two_level_prefix_before_feature(self):
|
|
"""More than one slash: no stripping; whole name must match (fails)."""
|
|
result = source_and_call('check_feature_branch "feat/fix/004-feat" "true"')
|
|
assert result.returncode != 0
|
|
|
|
def test_rejects_malformed_timestamp_with_prefix(self):
|
|
result = source_and_call('check_feature_branch "feat/2026031-143022-feat" "true"')
|
|
assert result.returncode != 0
|
|
|
|
|
|
# ── find_feature_dir_by_prefix Tests ─────────────────────────────────────────
|
|
|
|
|
|
@requires_bash
|
|
class TestFindFeatureDirByPrefix:
|
|
def test_timestamp_branch(self, tmp_path: Path):
|
|
"""Test 10: find_feature_dir_by_prefix with timestamp branch."""
|
|
(tmp_path / "specs" / "20260319-143022-user-auth").mkdir(parents=True)
|
|
result = source_and_call(
|
|
f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-user-auth"'
|
|
)
|
|
assert result.returncode == 0
|
|
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-user-auth"
|
|
|
|
def test_cross_branch_prefix(self, tmp_path: Path):
|
|
"""Test 11: find_feature_dir_by_prefix cross-branch (different suffix, same timestamp)."""
|
|
(tmp_path / "specs" / "20260319-143022-original-feat").mkdir(parents=True)
|
|
result = source_and_call(
|
|
f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-different-name"'
|
|
)
|
|
assert result.returncode == 0
|
|
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-original-feat"
|
|
|
|
def test_four_digit_sequential_prefix(self, tmp_path: Path):
|
|
"""find_feature_dir_by_prefix resolves 4+ digit sequential prefix."""
|
|
(tmp_path / "specs" / "1000-original-feat").mkdir(parents=True)
|
|
result = source_and_call(
|
|
f'find_feature_dir_by_prefix "{tmp_path}" "1000-different-name"'
|
|
)
|
|
assert result.returncode == 0
|
|
assert result.stdout.strip() == f"{tmp_path}/specs/1000-original-feat"
|
|
|
|
def test_sequential_with_single_path_prefix(self, tmp_path: Path):
|
|
"""Strip one optional prefix segment before prefix directory lookup."""
|
|
(tmp_path / "specs" / "004-only-dir").mkdir(parents=True)
|
|
result = source_and_call(
|
|
f'find_feature_dir_by_prefix "{tmp_path}" "feat/004-other-suffix"'
|
|
)
|
|
assert result.returncode == 0
|
|
assert result.stdout.strip() == f"{tmp_path}/specs/004-only-dir"
|
|
|
|
def test_timestamp_with_single_path_prefix_cross_branch(self, tmp_path: Path):
|
|
(tmp_path / "specs" / "20260319-143022-canonical").mkdir(parents=True)
|
|
result = source_and_call(
|
|
f'find_feature_dir_by_prefix "{tmp_path}" "hotfix/20260319-143022-alias"'
|
|
)
|
|
assert result.returncode == 0
|
|
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-canonical"
|
|
|
|
|
|
# ── get_feature_paths + single-prefix integration ───────────────────────────
|
|
|
|
|
|
class TestGetFeaturePathsSinglePrefix:
|
|
@requires_bash
|
|
def test_bash_specify_feature_prefixed_resolves_by_prefix(self, tmp_path: Path):
|
|
"""get_feature_paths: SPECIFY_FEATURE with one optional prefix uses effective name for lookup."""
|
|
(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}" && eval "$(get_feature_paths)" && printf "%s" "$FEATURE_DIR"'
|
|
)
|
|
result = subprocess.run(
|
|
["bash", "-c", cmd],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
assert result.stdout.strip() == str(tmp_path / "specs" / "001-target-spec")
|
|
|
|
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
|
def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path):
|
|
"""PowerShell Get-FeaturePathsEnv: same prefix stripping as bash."""
|
|
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, result.stderr
|
|
for line in result.stdout.splitlines():
|
|
if line.startswith("FEATURE_DIR="):
|
|
val = line.split("=", 1)[1].strip()
|
|
assert val == str(spec_dir)
|
|
break
|
|
else:
|
|
pytest.fail("FEATURE_DIR not found in PowerShell output")
|
|
|
|
|
|
# ── 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: No-git repo + timestamp creates spec dir with warning."""
|
|
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"
|
|
assert "git" in result.stderr.lower() or "warning" in result.stderr.lower()
|
|
|
|
|
|
# ── E2E Flow Tests ───────────────────────────────────────────────────────────
|
|
|
|
|
|
@requires_bash
|
|
class TestE2EFlow:
|
|
def test_e2e_timestamp(self, git_repo: Path):
|
|
"""Test 14: E2E timestamp flow — branch, dir, validation."""
|
|
run_script(git_repo, "--timestamp", "--short-name", "e2e-ts", "E2E timestamp test")
|
|
branch = subprocess.run(
|
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
cwd=git_repo,
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout.strip()
|
|
assert re.match(r"^\d{8}-\d{6}-e2e-ts$", branch), f"branch: {branch}"
|
|
assert (git_repo / "specs" / branch).is_dir()
|
|
val = source_and_call(f'check_feature_branch "{branch}" "true"')
|
|
assert val.returncode == 0
|
|
|
|
def test_e2e_sequential(self, git_repo: Path):
|
|
"""Test 15: E2E sequential flow (regression guard)."""
|
|
run_script(git_repo, "--short-name", "seq-feat", "Sequential feature")
|
|
branch = subprocess.run(
|
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
cwd=git_repo,
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout.strip()
|
|
assert re.match(r"^\d{3,}-seq-feat$", branch), f"branch: {branch}"
|
|
assert (git_repo / "specs" / branch).is_dir()
|
|
val = source_and_call(f'check_feature_branch "{branch}" "true"')
|
|
assert val.returncode == 0
|
|
|
|
|
|
# ── Allow Existing Branch Tests ──────────────────────────────────────────────
|
|
|
|
|
|
@requires_bash
|
|
class TestAllowExistingBranch:
|
|
def test_allow_existing_switches_to_branch(self, git_repo: Path):
|
|
"""T006: Pre-create branch, verify script switches to it."""
|
|
subprocess.run(
|
|
["git", "checkout", "-b", "004-pre-exist"],
|
|
cwd=git_repo, check=True, capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "checkout", "-"],
|
|
cwd=git_repo, check=True, capture_output=True,
|
|
)
|
|
result = run_script(
|
|
git_repo, "--allow-existing-branch", "--short-name", "pre-exist",
|
|
"--number", "4", "Pre-existing feature",
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
current = subprocess.run(
|
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
cwd=git_repo, capture_output=True, text=True,
|
|
).stdout.strip()
|
|
assert current == "004-pre-exist", f"expected 004-pre-exist, got {current}"
|
|
|
|
def test_allow_existing_already_on_branch(self, git_repo: Path):
|
|
"""T007: Verify success when already on the target branch."""
|
|
subprocess.run(
|
|
["git", "checkout", "-b", "005-already-on"],
|
|
cwd=git_repo, check=True, capture_output=True,
|
|
)
|
|
result = run_script(
|
|
git_repo, "--allow-existing-branch", "--short-name", "already-on",
|
|
"--number", "5", "Already on branch",
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
def test_allow_existing_creates_spec_dir(self, git_repo: Path):
|
|
"""T008: Verify spec directory created on existing branch."""
|
|
subprocess.run(
|
|
["git", "checkout", "-b", "006-spec-dir"],
|
|
cwd=git_repo, check=True, capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "checkout", "-"],
|
|
cwd=git_repo, check=True, capture_output=True,
|
|
)
|
|
result = run_script(
|
|
git_repo, "--allow-existing-branch", "--short-name", "spec-dir",
|
|
"--number", "6", "Spec dir feature",
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
assert (git_repo / "specs" / "006-spec-dir").is_dir()
|
|
assert (git_repo / "specs" / "006-spec-dir" / "spec.md").exists()
|
|
|
|
def test_without_flag_still_errors(self, git_repo: Path):
|
|
"""T009: Verify backwards compatibility (error without flag)."""
|
|
subprocess.run(
|
|
["git", "checkout", "-b", "007-no-flag"],
|
|
cwd=git_repo, check=True, capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "checkout", "-"],
|
|
cwd=git_repo, check=True, capture_output=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."""
|
|
subprocess.run(
|
|
["git", "checkout", "-b", "008-no-overwrite"],
|
|
cwd=git_repo, check=True, capture_output=True,
|
|
)
|
|
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")
|
|
subprocess.run(
|
|
["git", "checkout", "-"],
|
|
cwd=git_repo, check=True, capture_output=True,
|
|
)
|
|
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_branch_if_not_exists(self, git_repo: Path):
|
|
"""T011: Verify normal creation when branch doesn't exist."""
|
|
result = run_script(
|
|
git_repo, "--allow-existing-branch", "--short-name", "new-branch",
|
|
"New branch feature",
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
current = subprocess.run(
|
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
cwd=git_repo, capture_output=True, text=True,
|
|
).stdout.strip()
|
|
assert "new-branch" in current
|
|
|
|
def test_allow_existing_with_json(self, git_repo: Path):
|
|
"""T012: Verify JSON output is correct."""
|
|
import json
|
|
|
|
subprocess.run(
|
|
["git", "checkout", "-b", "009-json-test"],
|
|
cwd=git_repo, check=True, capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "checkout", "-"],
|
|
cwd=git_repo, check=True, capture_output=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 is silently ignored 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
|
|
|
|
def test_allow_existing_surfaces_checkout_error(self, git_repo: Path):
|
|
"""Checkout failures on an existing branch should include Git's stderr."""
|
|
shared_file = git_repo / "shared.txt"
|
|
shared_file.write_text("base\n")
|
|
subprocess.run(
|
|
["git", "add", "shared.txt"],
|
|
cwd=git_repo, check=True, capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "commit", "-m", "add shared file", "-q"],
|
|
cwd=git_repo, check=True, capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "checkout", "-b", "010-checkout-failure"],
|
|
cwd=git_repo, check=True, capture_output=True,
|
|
)
|
|
shared_file.write_text("branch version\n")
|
|
subprocess.run(
|
|
["git", "commit", "-am", "branch change", "-q"],
|
|
cwd=git_repo, check=True, capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "checkout", "-"],
|
|
cwd=git_repo, check=True, capture_output=True,
|
|
)
|
|
shared_file.write_text("uncommitted main change\n")
|
|
|
|
result = run_script(
|
|
git_repo, "--allow-existing-branch", "--short-name", "checkout-failure",
|
|
"--number", "10", "Checkout failure",
|
|
)
|
|
|
|
assert result.returncode != 0, "checkout should fail with conflicting local changes"
|
|
assert "Failed to switch to existing branch '010-checkout-failure'" in result.stderr
|
|
assert "would be overwritten by checkout" in result.stderr
|
|
assert "shared.txt" in 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
|
|
# Ensure the flag is referenced in script logic, not just declared
|
|
assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "")
|
|
|
|
def test_powershell_surfaces_checkout_errors(self):
|
|
"""Static guard: PS script preserves checkout stderr on existing-branch failures."""
|
|
contents = 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
|
|
|
|
|
|
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_no_branch_created(self, git_repo: Path):
|
|
"""T010: Dry-run does not create a git branch."""
|
|
result = run_script(
|
|
git_repo, "--dry-run", "--short-name", "no-branch", "No branch feature"
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
branches = subprocess.run(
|
|
["git", "branch", "--list", "*no-branch*"],
|
|
cwd=git_repo,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
|
|
assert branches.stdout.strip() == "", "branch should not exist after dry-run"
|
|
|
|
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_accounts_for_remote_branches(self, git_repo: Path):
|
|
"""Dry-run queries remote refs via ls-remote (no fetch) for accurate numbering."""
|
|
(git_repo / "specs" / "001-existing").mkdir(parents=True)
|
|
|
|
# Set up a bare remote and push (use subdirs of git_repo for isolation)
|
|
remote_dir = git_repo / "test-remote.git"
|
|
subprocess.run(
|
|
["git", "init", "--bare", str(remote_dir)],
|
|
check=True, capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "remote", "add", "origin", str(remote_dir)],
|
|
check=True, cwd=git_repo, capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "push", "-u", "origin", "HEAD"],
|
|
check=True, cwd=git_repo, capture_output=True,
|
|
)
|
|
|
|
# Clone into a second copy, create a higher-numbered branch, push it
|
|
second_clone = git_repo / "test-second-clone"
|
|
subprocess.run(
|
|
["git", "clone", str(remote_dir), str(second_clone)],
|
|
check=True, capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "config", "user.email", "test@example.com"],
|
|
cwd=second_clone, check=True, capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "config", "user.name", "Test User"],
|
|
cwd=second_clone, check=True, capture_output=True,
|
|
)
|
|
# Create branch 005 on the remote (higher than local 001)
|
|
subprocess.run(
|
|
["git", "checkout", "-b", "005-remote-only"],
|
|
cwd=second_clone, check=True, capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
["git", "push", "origin", "005-remote-only"],
|
|
cwd=second_clone, check=True, capture_output=True,
|
|
)
|
|
|
|
# Primary repo: dry-run should see 005 via ls-remote and return 006
|
|
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 == "006-remote-test", f"expected 006-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."""
|
|
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}"
|
|
# Verify no side effects
|
|
branches = subprocess.run(
|
|
["git", "branch", "--list", f"*ts-feat*"],
|
|
cwd=git_repo,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
|
|
assert branches.stdout.strip() == ""
|
|
|
|
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.fixture
|
|
def ps_git_repo(tmp_path: Path) -> Path:
|
|
"""Create a temp git repo with PowerShell 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,
|
|
)
|
|
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")
|
|
(tmp_path / ".specify" / "templates").mkdir(parents=True)
|
|
return tmp_path
|
|
|
|
|
|
@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_no_branch_created(self, ps_git_repo: Path):
|
|
"""PowerShell -DryRun does not create a git branch."""
|
|
result = run_ps_script(
|
|
ps_git_repo, "-DryRun", "-ShortName", "no-ps-branch", "No branch"
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
branches = subprocess.run(
|
|
["git", "branch", "--list", "*no-ps-branch*"],
|
|
cwd=ps_git_repo,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}"
|
|
assert branches.stdout.strip() == "", "branch should not exist after dry-run"
|
|
|
|
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}"
|
|
|
|
|
|
# ── GIT_BRANCH_NAME Override Tests ──────────────────────────────────────────
|
|
|
|
|
|
@requires_bash
|
|
class TestGitBranchNameOverrideBash:
|
|
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.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.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.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.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_fallback_to_branch_lookup(self, git_repo: Path):
|
|
"""Without env var or feature.json, falls back to branch-based lookup."""
|
|
subprocess.run(["git", "checkout", "-q", "-b", "001-test-feat"], cwd=git_repo, check=True)
|
|
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, result.stderr
|
|
for line in result.stdout.splitlines():
|
|
if line.startswith("FEATURE_DIR="):
|
|
val = line.split("=", 1)[1].strip("'\"")
|
|
assert val == str(spec_dir)
|
|
break
|
|
else:
|
|
pytest.fail("FEATURE_DIR not found in output")
|
|
|
|
@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")
|