mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Treat an explicit feature number as a preference when an existing spec directory already uses that prefix. Advance to the next available spec prefix and warn without fetching or scanning git branches. Keep Bash, PowerShell, and Python variants aligned. Preserve 64-bit numbering, timestamp mode, dry-run output, matching-file behavior, and exact-directory reuse through the allow-existing option. Assisted-by: Codex (model: GPT-5, autonomous)
970 lines
30 KiB
Python
970 lines
30 KiB
Python
"""Parity tests for the Python create-new-feature port."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from scripts.python import create_new_feature
|
|
from scripts.python.common import persist_feature_json
|
|
from tests.conftest import requires_bash
|
|
from tests.parity_helpers import (
|
|
HAS_POWERSHELL,
|
|
bash_cmd,
|
|
install_scripts,
|
|
json_stdout,
|
|
make_repo,
|
|
normalize_repo_paths,
|
|
normalize_script_names,
|
|
ps_cmd,
|
|
py_cmd,
|
|
run,
|
|
)
|
|
|
|
SCRIPT = "create-new-feature"
|
|
TEMPLATE_BODY = "# Spec Template\n\nBody.\n"
|
|
|
|
|
|
def _setup_repo(tmp_path: Path, name: str = "proj") -> Path:
|
|
repo = make_repo(tmp_path, name)
|
|
install_scripts(repo, SCRIPT)
|
|
templates = repo / ".specify" / "templates"
|
|
templates.mkdir(parents=True)
|
|
(templates / "spec-template.md").write_text(TEMPLATE_BODY, encoding="utf-8")
|
|
return repo
|
|
|
|
|
|
def _normalized_error_text(stderr: str, repo: Path) -> str:
|
|
stderr = re.sub(r"\x1b\[[0-9;]*m", "", stderr)
|
|
stderr = re.sub(r"(?m)^\s*\|\s?", "", stderr)
|
|
stderr = normalize_repo_paths(stderr, repo).replace("-Number", "--number")
|
|
return " ".join(stderr.split())
|
|
|
|
|
|
@pytest.fixture
|
|
def repo(tmp_path: Path) -> Path:
|
|
return _setup_repo(tmp_path)
|
|
|
|
|
|
@pytest.fixture
|
|
def repo_pair(tmp_path: Path) -> tuple[Path, Path]:
|
|
return _setup_repo(tmp_path, "proj-a"), _setup_repo(tmp_path, "proj-b")
|
|
|
|
|
|
def _run_all_variants_allow_existing(
|
|
repo: Path, *, number: str, short_name: str
|
|
):
|
|
"""Run each create-new-feature variant with the allow-existing options."""
|
|
common_args = (
|
|
"--json",
|
|
"--dry-run",
|
|
"--number",
|
|
number,
|
|
"--allow-existing-branch",
|
|
"--short-name",
|
|
short_name,
|
|
"x",
|
|
)
|
|
bash = run(bash_cmd(repo, SCRIPT, *common_args), repo)
|
|
py = run(py_cmd(repo, SCRIPT, *common_args), repo)
|
|
ps = run(
|
|
ps_cmd(
|
|
repo,
|
|
SCRIPT,
|
|
"-Json",
|
|
"-DryRun",
|
|
"-Number",
|
|
number,
|
|
"-AllowExistingBranch",
|
|
"-ShortName",
|
|
short_name,
|
|
"x",
|
|
),
|
|
repo,
|
|
)
|
|
return bash, ps, py
|
|
|
|
|
|
def test_python_prefix_scan_tolerates_permission_error(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Python matches shell variants when a spec directory cannot be listed."""
|
|
specs_dir = tmp_path / "specs"
|
|
specs_dir.mkdir()
|
|
|
|
def deny_listing(_path: Path):
|
|
raise PermissionError("denied")
|
|
|
|
monkeypatch.setattr(Path, "iterdir", deny_listing)
|
|
|
|
assert not create_new_feature._has_spec_prefix_conflict(
|
|
specs_dir,
|
|
"001",
|
|
specs_dir / "001-x",
|
|
allow_existing=False,
|
|
)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.parametrize(
|
|
"description",
|
|
[
|
|
"Add user authentication system",
|
|
"I want to add the new API rate limiting feature for users",
|
|
"Fix UI for DB sync",
|
|
"a to the of",
|
|
],
|
|
ids=["plain", "stop_words", "acronyms", "all_stop_words_fallback"],
|
|
)
|
|
def test_python_branch_name_generation_matches_bash(
|
|
repo: Path, description: str
|
|
) -> None:
|
|
bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo)
|
|
py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo)
|
|
|
|
assert py.returncode == bash.returncode == 0
|
|
assert py.stderr == bash.stderr == ""
|
|
assert json_stdout(py) == json_stdout(bash)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.parametrize(
|
|
"args",
|
|
[
|
|
("--json", "--dry-run", "--number", "7", "add rate limiting"),
|
|
("--json", "--dry-run", "--number", "010", "add rate limiting"),
|
|
],
|
|
ids=["explicit_number", "leading_zero_number"],
|
|
)
|
|
def test_python_number_flag_matches_bash(repo: Path, args: tuple[str, ...]) -> None:
|
|
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
|
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
|
|
|
assert py.returncode == bash.returncode == 0
|
|
assert json_stdout(py) == json_stdout(bash)
|
|
|
|
|
|
@requires_bash
|
|
def test_python_sequential_numbering_matches_bash(repo: Path) -> None:
|
|
for name in ("001-first", "0005-fourdigit", "20260101-120000-stamp", "12-short"):
|
|
(repo / "specs" / name).mkdir(parents=True)
|
|
|
|
bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", "add rate limiting"), repo)
|
|
py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", "add rate limiting"), repo)
|
|
|
|
assert py.returncode == bash.returncode == 0
|
|
assert json_stdout(py) == json_stdout(bash)
|
|
assert json_stdout(py)["FEATURE_NUM"] == "006"
|
|
|
|
|
|
@requires_bash
|
|
def test_all_variants_timestamp_mode_match_shape(repo: Path) -> None:
|
|
args = ("--json", "--dry-run", "--timestamp", "--short-name", "user-auth", "x")
|
|
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
|
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
|
results = [bash, py]
|
|
if HAS_POWERSHELL:
|
|
results.append(
|
|
run(
|
|
ps_cmd(
|
|
repo,
|
|
SCRIPT,
|
|
"-Json",
|
|
"-DryRun",
|
|
"-Timestamp",
|
|
"-ShortName",
|
|
"user-auth",
|
|
"x",
|
|
),
|
|
repo,
|
|
)
|
|
)
|
|
|
|
assert all(result.returncode == 0 for result in results)
|
|
# Timestamps may straddle a second boundary, so compare shape and suffix.
|
|
for result in results:
|
|
data = json_stdout(result)
|
|
assert re.fullmatch(r"\d{8}-\d{6}-user-auth", data["BRANCH_NAME"])
|
|
assert data["BRANCH_NAME"].startswith(data["FEATURE_NUM"])
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
|
def test_all_variants_timestamp_number_warning_matches(repo: Path) -> None:
|
|
args = (
|
|
"--json",
|
|
"--dry-run",
|
|
"--timestamp",
|
|
"--number",
|
|
"5",
|
|
"--short-name",
|
|
"ua",
|
|
"x",
|
|
)
|
|
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
|
ps = run(
|
|
ps_cmd(
|
|
repo,
|
|
SCRIPT,
|
|
"-Json",
|
|
"-DryRun",
|
|
"-Timestamp",
|
|
"-Number",
|
|
"5",
|
|
"-ShortName",
|
|
"ua",
|
|
"x",
|
|
),
|
|
repo,
|
|
)
|
|
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
|
|
|
assert bash.returncode == ps.returncode == py.returncode == 0
|
|
assert json_stdout(ps)
|
|
assert (
|
|
py.stderr
|
|
== bash.stderr
|
|
== ps.stderr.replace("-Number", "--number").replace(
|
|
"-Timestamp", "--timestamp"
|
|
)
|
|
== "[specify] Warning: --number is ignored when --timestamp is used\n"
|
|
)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
|
def test_all_variants_invalid_number_fails_cleanly(repo: Path) -> None:
|
|
args = ("--json", "--dry-run", "--number", "abc", "add rate limiting")
|
|
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
|
ps = run(
|
|
ps_cmd(
|
|
repo,
|
|
SCRIPT,
|
|
"-Json",
|
|
"-DryRun",
|
|
"-Number",
|
|
"abc",
|
|
"add rate limiting",
|
|
),
|
|
repo,
|
|
)
|
|
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
|
|
|
assert bash.returncode == ps.returncode == py.returncode == 1
|
|
assert bash.stdout == ps.stdout == py.stdout == ""
|
|
expected = "Error: --number must be an unsigned integer, got 'abc'"
|
|
for result in (bash, ps, py):
|
|
assert expected in _normalized_error_text(result.stderr, repo)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
|
def test_all_variants_negative_number_fails_cleanly(repo: Path) -> None:
|
|
args = ("--json", "--dry-run", "--number", "-1", "add rate limiting")
|
|
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
|
ps = run(
|
|
ps_cmd(
|
|
repo,
|
|
SCRIPT,
|
|
"-Json",
|
|
"-DryRun",
|
|
"-Number",
|
|
"-1",
|
|
"add rate limiting",
|
|
),
|
|
repo,
|
|
)
|
|
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
|
|
|
assert bash.returncode == ps.returncode == py.returncode == 1
|
|
assert bash.stdout == ps.stdout == py.stdout == ""
|
|
expected = "Error: --number must be an unsigned integer, got '-1'"
|
|
for result in (bash, ps, py):
|
|
assert expected in _normalized_error_text(result.stderr, repo)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
|
@pytest.mark.parametrize("digit_count", [244, 5000])
|
|
def test_all_variants_oversized_number_fails_cleanly(
|
|
repo: Path, digit_count: int
|
|
) -> None:
|
|
number = "9" * digit_count
|
|
bash = run(
|
|
bash_cmd(
|
|
repo,
|
|
SCRIPT,
|
|
"--json",
|
|
"--dry-run",
|
|
"--number",
|
|
number,
|
|
"add rate limiting",
|
|
),
|
|
repo,
|
|
)
|
|
ps = run(
|
|
ps_cmd(
|
|
repo,
|
|
SCRIPT,
|
|
"-Json",
|
|
"-DryRun",
|
|
"-Number",
|
|
number,
|
|
"add rate limiting",
|
|
),
|
|
repo,
|
|
)
|
|
py = run(
|
|
py_cmd(
|
|
repo,
|
|
SCRIPT,
|
|
"--json",
|
|
"--dry-run",
|
|
"--number",
|
|
number,
|
|
"add rate limiting",
|
|
),
|
|
repo,
|
|
)
|
|
|
|
assert bash.returncode == ps.returncode == py.returncode == 1
|
|
assert bash.stdout == ps.stdout == py.stdout == ""
|
|
expected = (
|
|
f"Error: --number must be between 0 and {2**63 - 1}, got '{number}'"
|
|
)
|
|
for result in (bash, ps, py):
|
|
assert expected in _normalized_error_text(result.stderr, repo)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
|
def test_all_variants_branch_truncation_match(repo: Path) -> None:
|
|
args = ("--json", "--dry-run", "--short-name", "a" * 300, "x")
|
|
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
|
ps = run(
|
|
ps_cmd(
|
|
repo,
|
|
SCRIPT,
|
|
"-Json",
|
|
"-DryRun",
|
|
"-ShortName",
|
|
"a" * 300,
|
|
"x",
|
|
),
|
|
repo,
|
|
)
|
|
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
|
|
|
assert bash.returncode == ps.returncode == py.returncode == 0
|
|
assert bash.stderr == ps.stderr == py.stderr
|
|
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
|
assert len(json_stdout(py)["BRANCH_NAME"]) == 244
|
|
|
|
|
|
@requires_bash
|
|
def test_python_full_run_matches_bash(repo_pair: tuple[Path, Path]) -> None:
|
|
repo_a, repo_b = repo_pair
|
|
description = "Add user authentication system"
|
|
|
|
bash = run(bash_cmd(repo_a, SCRIPT, "--json", description), repo_a)
|
|
py = run(py_cmd(repo_b, SCRIPT, "--json", description), repo_b)
|
|
|
|
assert py.returncode == bash.returncode == 0
|
|
assert normalize_repo_paths(py.stdout, repo_b) == normalize_repo_paths(
|
|
bash.stdout, repo_a
|
|
)
|
|
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
|
|
bash.stderr, repo_a
|
|
)
|
|
|
|
branch = json_stdout(py)["BRANCH_NAME"]
|
|
for repo in repo_pair:
|
|
spec = repo / "specs" / branch / "spec.md"
|
|
assert spec.read_text(encoding="utf-8") == TEMPLATE_BODY
|
|
assert (repo_b / ".specify" / "feature.json").read_bytes() == (
|
|
repo_a / ".specify" / "feature.json"
|
|
).read_bytes()
|
|
|
|
|
|
@requires_bash
|
|
def test_python_missing_template_warning_matches_bash(
|
|
repo_pair: tuple[Path, Path],
|
|
) -> None:
|
|
repo_a, repo_b = repo_pair
|
|
for repo in repo_pair:
|
|
(repo / ".specify" / "templates" / "spec-template.md").unlink()
|
|
|
|
bash = run(bash_cmd(repo_a, SCRIPT, "--json", "add rate limiting"), repo_a)
|
|
py = run(py_cmd(repo_b, SCRIPT, "--json", "add rate limiting"), repo_b)
|
|
|
|
assert py.returncode == bash.returncode == 0
|
|
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
|
|
bash.stderr, repo_a
|
|
)
|
|
branch = json_stdout(py)["BRANCH_NAME"]
|
|
for repo in repo_pair:
|
|
assert (repo / "specs" / branch / "spec.md").read_text(encoding="utf-8") == ""
|
|
|
|
|
|
@requires_bash
|
|
def test_python_existing_prefix_auto_correct_matches_bash(
|
|
repo_pair: tuple[Path, Path],
|
|
) -> None:
|
|
repo_a, repo_b = repo_pair
|
|
description = "add rate limiting"
|
|
|
|
assert (
|
|
run(
|
|
bash_cmd(repo_a, SCRIPT, "--json", "--number", "1", description), repo_a
|
|
).returncode
|
|
== 0
|
|
)
|
|
assert (
|
|
run(
|
|
py_cmd(repo_b, SCRIPT, "--json", "--number", "1", description), repo_b
|
|
).returncode
|
|
== 0
|
|
)
|
|
|
|
bash = run(bash_cmd(repo_a, SCRIPT, "--json", "--number", "1", description), repo_a)
|
|
py = run(py_cmd(repo_b, SCRIPT, "--json", "--number", "1", description), repo_b)
|
|
|
|
assert py.returncode == bash.returncode == 0
|
|
assert json_stdout(py)["FEATURE_NUM"] == json_stdout(bash)["FEATURE_NUM"] == "002"
|
|
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
|
|
bash.stderr, repo_a
|
|
)
|
|
|
|
bash_retry = run(
|
|
bash_cmd(
|
|
repo_a,
|
|
SCRIPT,
|
|
"--json",
|
|
"--number",
|
|
"1",
|
|
"--allow-existing-branch",
|
|
description,
|
|
),
|
|
repo_a,
|
|
)
|
|
py_retry = run(
|
|
py_cmd(
|
|
repo_b,
|
|
SCRIPT,
|
|
"--json",
|
|
"--number",
|
|
"1",
|
|
"--allow-existing-branch",
|
|
description,
|
|
),
|
|
repo_b,
|
|
)
|
|
assert py_retry.returncode == bash_retry.returncode == 0
|
|
assert normalize_repo_paths(py_retry.stdout, repo_b) == normalize_repo_paths(
|
|
bash_retry.stdout, repo_a
|
|
)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.parametrize(
|
|
"args",
|
|
[
|
|
(),
|
|
(" ",),
|
|
("--short-name",),
|
|
("--number",),
|
|
],
|
|
ids=["missing_description", "whitespace_description", "short_name_no_value", "number_no_value"],
|
|
)
|
|
def test_python_argument_errors_match_bash(repo: Path, args: tuple[str, ...]) -> None:
|
|
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
|
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
|
|
|
assert py.returncode == bash.returncode == 1
|
|
assert py.stdout == bash.stdout == ""
|
|
assert normalize_script_names(py.stderr, repo, SCRIPT) == normalize_script_names(
|
|
bash.stderr, repo, SCRIPT
|
|
)
|
|
|
|
|
|
@requires_bash
|
|
def test_python_help_matches_bash(repo: Path) -> None:
|
|
bash = run(bash_cmd(repo, SCRIPT, "--help"), repo)
|
|
py = run(py_cmd(repo, SCRIPT, "--help"), repo)
|
|
|
|
assert py.returncode == bash.returncode == 0
|
|
assert py.stderr == bash.stderr == ""
|
|
assert normalize_script_names(py.stdout, repo, SCRIPT) == normalize_script_names(
|
|
bash.stdout, repo, SCRIPT
|
|
)
|
|
|
|
|
|
@requires_bash
|
|
def test_python_persists_relative_feature_json(repo: Path) -> None:
|
|
py = run(py_cmd(repo, SCRIPT, "--json", "add rate limiting"), repo)
|
|
|
|
assert py.returncode == 0, py.stderr
|
|
branch = json_stdout(py)["BRANCH_NAME"]
|
|
feature_json = (repo / ".specify" / "feature.json").read_text(encoding="utf-8")
|
|
assert feature_json == f'{{"feature_directory":"specs/{branch}"}}\n'
|
|
|
|
|
|
def test_persist_feature_json_avoids_platform_newline_translation(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
def windows_write_text(path: Path, data: str, **kwargs) -> int:
|
|
encoding = kwargs.get("encoding") or "utf-8"
|
|
return path.write_bytes(data.replace("\n", "\r\n").encode(encoding))
|
|
|
|
monkeypatch.setattr(Path, "write_text", windows_write_text)
|
|
|
|
persist_feature_json(tmp_path, "specs/001-test")
|
|
|
|
assert (tmp_path / ".specify" / "feature.json").read_bytes() == (
|
|
b'{"feature_directory":"specs/001-test"}\n'
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
|
@pytest.mark.parametrize(
|
|
("py_args", "ps_args"),
|
|
[
|
|
(
|
|
("--json", "--dry-run", "Add user authentication system"),
|
|
("-Json", "-DryRun", "Add user authentication system"),
|
|
),
|
|
(
|
|
("--json", "--dry-run", "--short-name", "My Fancy Name", "x"),
|
|
("-Json", "-DryRun", "-ShortName", "My Fancy Name", "x"),
|
|
),
|
|
(
|
|
("--json", "--dry-run", "--number", "7", "add rate limiting"),
|
|
("-Json", "-DryRun", "-Number", "7", "add rate limiting"),
|
|
),
|
|
],
|
|
ids=["plain", "short_name", "number"],
|
|
)
|
|
def test_python_json_output_matches_powershell(
|
|
repo: Path, py_args: tuple[str, ...], ps_args: tuple[str, ...]
|
|
) -> None:
|
|
ps = run(ps_cmd(repo, SCRIPT, *ps_args), repo)
|
|
py = run(py_cmd(repo, SCRIPT, *py_args), repo)
|
|
|
|
assert py.returncode == ps.returncode == 0
|
|
assert json_stdout(py) == json_stdout(ps)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
|
@pytest.mark.parametrize("number", ["-1", "+1"], ids=["negative", "positive_sign"])
|
|
def test_all_variants_reject_signed_number(repo: Path, number: str) -> None:
|
|
bash = run(
|
|
bash_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", number, "x"),
|
|
repo,
|
|
)
|
|
ps = run(
|
|
ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "-Number", number, "x"),
|
|
repo,
|
|
)
|
|
py = run(
|
|
py_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", number, "x"),
|
|
repo,
|
|
)
|
|
|
|
assert bash.returncode == ps.returncode == py.returncode == 1
|
|
expected = f"Error: --number must be an unsigned integer, got '{number}'"
|
|
for result in (bash, ps, py):
|
|
assert expected in _normalized_error_text(result.stderr, repo)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
|
@pytest.mark.parametrize("timestamp", [False, True], ids=["numbered", "timestamp"])
|
|
def test_all_variants_treat_empty_number_as_omitted(
|
|
repo: Path, timestamp: bool
|
|
) -> None:
|
|
if not timestamp:
|
|
specs_dir = repo / "specs"
|
|
(specs_dir / "20260318-sequential").mkdir(parents=True)
|
|
(specs_dir / "20260319-143022-timestamp").mkdir()
|
|
|
|
bash_args = ["--json", "--dry-run", "--number", ""]
|
|
ps_args = ["-Json", "-DryRun", "-Number", ""]
|
|
py_args = ["--json", "--dry-run", "--number", ""]
|
|
if timestamp:
|
|
bash_args.append("--timestamp")
|
|
ps_args.append("-Timestamp")
|
|
py_args.append("--timestamp")
|
|
bash_args.append("x")
|
|
ps_args.append("x")
|
|
py_args.append("x")
|
|
|
|
bash = run(bash_cmd(repo, SCRIPT, *bash_args), repo)
|
|
ps = run(ps_cmd(repo, SCRIPT, *ps_args), repo)
|
|
py = run(py_cmd(repo, SCRIPT, *py_args), repo)
|
|
|
|
assert bash.returncode == ps.returncode == py.returncode == 0
|
|
assert bash.stderr == ps.stderr == py.stderr == ""
|
|
if not timestamp:
|
|
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
|
@pytest.mark.parametrize(
|
|
("number", "returncode"),
|
|
[
|
|
(str(2**63 - 1), 0),
|
|
(str(2**63), 1),
|
|
],
|
|
ids=["int64_max", "int64_overflow"],
|
|
)
|
|
def test_all_variants_share_int64_number_range(
|
|
repo: Path, number: str, returncode: int
|
|
) -> None:
|
|
bash = run(
|
|
bash_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", number, "x"),
|
|
repo,
|
|
)
|
|
ps = run(
|
|
ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "-Number", number, "x"),
|
|
repo,
|
|
)
|
|
py = run(
|
|
py_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", number, "x"),
|
|
repo,
|
|
)
|
|
|
|
assert bash.returncode == ps.returncode == py.returncode == returncode
|
|
if returncode == 0:
|
|
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
|
else:
|
|
assert bash.stdout == ps.stdout == py.stdout == ""
|
|
expected = f"Error: --number must be between 0 and {2**63 - 1}, got '{number}'"
|
|
for result in (bash, ps, py):
|
|
assert expected in _normalized_error_text(result.stderr, repo)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
|
def test_all_variants_reject_exhausted_auto_number_range(repo: Path) -> None:
|
|
(repo / "specs" / f"{2**63 - 1}-existing").mkdir(parents=True)
|
|
|
|
bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", "x"), repo)
|
|
ps = run(ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "x"), repo)
|
|
py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", "x"), repo)
|
|
|
|
assert bash.returncode == ps.returncode == py.returncode == 1
|
|
assert bash.stdout == ps.stdout == py.stdout == ""
|
|
expected = f"Error: feature number must be between 0 and {2**63 - 1}, got '{2**63}'"
|
|
for result in (bash, ps, py):
|
|
assert expected in _normalized_error_text(result.stderr, repo)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
|
@pytest.mark.parametrize("prefix", [2**63, 2**64 + 5])
|
|
def test_all_variants_ignore_out_of_range_existing_prefix(
|
|
repo: Path, prefix: int
|
|
) -> None:
|
|
(repo / "specs" / f"{prefix}-existing").mkdir(parents=True)
|
|
|
|
bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", "x"), repo)
|
|
ps = run(ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "x"), repo)
|
|
py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", "x"), repo)
|
|
|
|
assert bash.returncode == ps.returncode == py.returncode == 0
|
|
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
|
assert json_stdout(py)["FEATURE_NUM"] == "001"
|
|
|
|
|
|
def test_python_ignores_unconvertibly_large_existing_prefix() -> None:
|
|
class Entry:
|
|
name = f"{'9' * 5000}-existing"
|
|
|
|
@staticmethod
|
|
def is_dir() -> bool:
|
|
return True
|
|
|
|
class SpecsDir:
|
|
@staticmethod
|
|
def is_dir() -> bool:
|
|
return True
|
|
|
|
@staticmethod
|
|
def iterdir() -> list[Entry]:
|
|
return [Entry()]
|
|
|
|
assert create_new_feature._get_highest_from_specs(SpecsDir()) == 0
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
|
def test_all_variants_text_mode_match(repo: Path) -> None:
|
|
bash = run(bash_cmd(repo, SCRIPT, "--dry-run", "--number", "7", "x"), repo)
|
|
ps = run(ps_cmd(repo, SCRIPT, "-DryRun", "-Number", "7", "x"), repo)
|
|
py = run(py_cmd(repo, SCRIPT, "--dry-run", "--number", "7", "x"), repo)
|
|
|
|
assert bash.returncode == ps.returncode == py.returncode == 0
|
|
assert bash.stderr == ps.stderr == py.stderr == ""
|
|
assert (
|
|
normalize_repo_paths(bash.stdout, repo)
|
|
== normalize_repo_paths(ps.stdout, repo)
|
|
== normalize_repo_paths(py.stdout, repo)
|
|
)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
|
def test_all_variants_non_dry_text_mode_match(tmp_path: Path) -> None:
|
|
bash_repo = _setup_repo(tmp_path, "bash")
|
|
ps_repo = _setup_repo(tmp_path, "powershell")
|
|
py_repo = _setup_repo(tmp_path, "python")
|
|
|
|
bash = run(
|
|
bash_cmd(bash_repo, SCRIPT, "--number", "7", "x"), bash_repo
|
|
)
|
|
ps = run(
|
|
ps_cmd(ps_repo, SCRIPT, "-Number", "7", "x"), ps_repo
|
|
)
|
|
py = run(py_cmd(py_repo, SCRIPT, "--number", "7", "x"), py_repo)
|
|
|
|
assert bash.returncode == ps.returncode == py.returncode == 0
|
|
assert (
|
|
normalize_repo_paths(bash.stdout, bash_repo)
|
|
== normalize_repo_paths(py.stdout, py_repo)
|
|
)
|
|
assert (
|
|
normalize_repo_paths(bash.stderr, bash_repo)
|
|
== normalize_repo_paths(py.stderr, py_repo)
|
|
)
|
|
ps_stdout = normalize_repo_paths(ps.stdout, ps_repo)
|
|
ps_stderr = normalize_repo_paths(ps.stderr, ps_repo)
|
|
assert "$env:SPECIFY_FEATURE = '007-x'" in ps_stdout
|
|
assert (
|
|
"$env:SPECIFY_FEATURE_DIRECTORY = '<REPO>/specs/007-x'" in ps_stdout
|
|
)
|
|
assert "$env:SPECIFY_FEATURE = '007-x'" in ps_stderr
|
|
assert (
|
|
"$env:SPECIFY_FEATURE_DIRECTORY = '<REPO>/specs/007-x'" in ps_stderr
|
|
)
|
|
|
|
|
|
@requires_bash
|
|
def test_python_persist_hints_match_bash_for_spaced_repo_path(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Paths with spaces must be quoted identically (shlex.quote format) so
|
|
the side-by-side text/stderr comparison holds."""
|
|
bash_repo = _setup_repo(tmp_path, "my proj a")
|
|
py_repo = _setup_repo(tmp_path, "my proj b")
|
|
|
|
bash = run(bash_cmd(bash_repo, SCRIPT, "--number", "7", "x"), bash_repo)
|
|
py = run(py_cmd(py_repo, SCRIPT, "--number", "7", "x"), py_repo)
|
|
|
|
assert bash.returncode == py.returncode == 0, bash.stderr + py.stderr
|
|
assert normalize_repo_paths(bash.stdout, bash_repo) == normalize_repo_paths(
|
|
py.stdout, py_repo
|
|
)
|
|
assert normalize_repo_paths(bash.stderr, bash_repo) == normalize_repo_paths(
|
|
py.stderr, py_repo
|
|
)
|
|
assert "export SPECIFY_FEATURE_DIRECTORY='<REPO>/specs/007-x'" in (
|
|
normalize_repo_paths(py.stderr, py_repo)
|
|
)
|
|
|
|
|
|
def test_python_powershell_persistence_assignments_escape_quotes() -> None:
|
|
assert create_new_feature._persistence_assignments(
|
|
"007-x", r"C:\repo\O'Brien", powershell=True
|
|
) == (
|
|
"$env:SPECIFY_FEATURE = '007-x'",
|
|
"$env:SPECIFY_FEATURE_DIRECTORY = 'C:\\repo\\O''Brien'",
|
|
)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
|
def test_all_variants_persist_symlinked_specs_path_lexically(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repos = [
|
|
_setup_repo(tmp_path, "bash"),
|
|
_setup_repo(tmp_path, "powershell"),
|
|
_setup_repo(tmp_path, "python"),
|
|
]
|
|
for current in repos:
|
|
specs_target = tmp_path / f"{current.name}-specs"
|
|
specs_target.mkdir()
|
|
try:
|
|
(current / "specs").symlink_to(
|
|
specs_target, target_is_directory=True
|
|
)
|
|
except (OSError, NotImplementedError):
|
|
pytest.skip("Symlinks are not available in this environment")
|
|
|
|
bash = run(
|
|
bash_cmd(repos[0], SCRIPT, "--json", "--number", "7", "x"),
|
|
repos[0],
|
|
)
|
|
ps = run(
|
|
ps_cmd(repos[1], SCRIPT, "-Json", "-Number", "7", "x"),
|
|
repos[1],
|
|
)
|
|
py = run(
|
|
py_cmd(repos[2], SCRIPT, "--json", "--number", "7", "x"),
|
|
repos[2],
|
|
)
|
|
|
|
assert bash.returncode == ps.returncode == py.returncode == 0
|
|
expected = '{"feature_directory":"specs/007-x"}'
|
|
for current in repos:
|
|
assert (
|
|
current / ".specify" / "feature.json"
|
|
).read_text(encoding="utf-8").strip() == expected
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
|
def test_all_variants_allow_existing_branch(repo: Path) -> None:
|
|
feature_dir = repo / "specs" / "001-x"
|
|
feature_dir.mkdir(parents=True)
|
|
spec_file = feature_dir / "spec.md"
|
|
spec_file.write_text("existing\n", encoding="utf-8")
|
|
|
|
bash = run(
|
|
bash_cmd(
|
|
repo,
|
|
SCRIPT,
|
|
"--json",
|
|
"--number",
|
|
"1",
|
|
"--allow-existing-branch",
|
|
"x",
|
|
),
|
|
repo,
|
|
)
|
|
ps = run(
|
|
ps_cmd(
|
|
repo,
|
|
SCRIPT,
|
|
"-Json",
|
|
"-Number",
|
|
"1",
|
|
"-AllowExistingBranch",
|
|
"x",
|
|
),
|
|
repo,
|
|
)
|
|
py = run(
|
|
py_cmd(
|
|
repo,
|
|
SCRIPT,
|
|
"--json",
|
|
"--number",
|
|
"1",
|
|
"--allow-existing-branch",
|
|
"x",
|
|
),
|
|
repo,
|
|
)
|
|
|
|
assert bash.returncode == ps.returncode == py.returncode == 0
|
|
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
|
assert spec_file.read_text(encoding="utf-8") == "existing\n"
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
|
def test_all_variants_allow_existing_prefers_exact_dir_over_sibling(
|
|
repo: Path,
|
|
) -> None:
|
|
"""Allow-existing preserves exact reuse even when a sibling shares its prefix."""
|
|
(repo / "specs" / "004-pre-exist").mkdir(parents=True)
|
|
(repo / "specs" / "004-other").mkdir()
|
|
|
|
bash, ps, py = _run_all_variants_allow_existing(
|
|
repo, number="4", short_name="pre-exist"
|
|
)
|
|
|
|
assert bash.returncode == ps.returncode == py.returncode == 0
|
|
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
|
assert json_stdout(py)["BRANCH_NAME"] == "004-pre-exist"
|
|
for result in (bash, ps, py):
|
|
assert "conflicts with an existing spec directory" not in result.stderr
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
|
def test_all_variants_allow_existing_reuses_truncated_exact_dir(repo: Path) -> None:
|
|
"""Allow-existing compares the canonical truncated feature directory name."""
|
|
short_name = "a" * 300
|
|
expected_branch = f"001-{'a' * 240}"
|
|
(repo / "specs" / expected_branch).mkdir(parents=True)
|
|
|
|
bash, ps, py = _run_all_variants_allow_existing(
|
|
repo, number="1", short_name=short_name
|
|
)
|
|
|
|
assert bash.returncode == ps.returncode == py.returncode == 0
|
|
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
|
assert json_stdout(py)["BRANCH_NAME"] == expected_branch
|
|
for result in (bash, ps, py):
|
|
assert "conflicts with an existing spec directory" not in result.stderr
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
|
def test_all_variants_existing_prefix_auto_correct_diagnostics(repo: Path) -> None:
|
|
(repo / "specs" / "001-x").mkdir(parents=True)
|
|
expected = "conflicts with an existing spec directory; using 002 instead"
|
|
|
|
bash = run(
|
|
bash_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", "1", "x"),
|
|
repo,
|
|
)
|
|
ps = run(
|
|
ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "-Number", "1", "x"),
|
|
repo,
|
|
)
|
|
py = run(
|
|
py_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", "1", "x"),
|
|
repo,
|
|
)
|
|
|
|
assert bash.returncode == ps.returncode == py.returncode == 0
|
|
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
|
for result in (bash, ps, py):
|
|
assert expected in _normalized_error_text(result.stderr, repo)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
|
def test_all_variants_corrected_prefix_skips_timestamp_collision(repo: Path) -> None:
|
|
"""Auto-correction skips candidates owned by timestamp directories."""
|
|
specs_dir = repo / "specs"
|
|
(specs_dir / "001-existing").mkdir(parents=True)
|
|
(specs_dir / "20260318-sequential").mkdir()
|
|
(specs_dir / "20260319-143022-timestamp").mkdir()
|
|
|
|
bash = run(
|
|
bash_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", "1", "x"),
|
|
repo,
|
|
)
|
|
ps = run(
|
|
ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "-Number", "1", "x"),
|
|
repo,
|
|
)
|
|
py = run(
|
|
py_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", "1", "x"),
|
|
repo,
|
|
)
|
|
|
|
assert bash.returncode == ps.returncode == py.returncode == 0
|
|
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
|
assert json_stdout(py)["FEATURE_NUM"] == "20260320"
|
|
for result in (bash, ps, py):
|
|
assert "using 20260320 instead" in result.stderr
|