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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion extensions/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"git": {
"name": "Git Branching Workflow",
"id": "git",
"version": "1.0.1",
"version": "1.0.2",
"description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
Expand Down
2 changes: 1 addition & 1 deletion extensions/git/extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ schema_version: "1.0"
extension:
id: git
name: "Git Branching Workflow"
version: "1.0.1"
version: "1.0.2"
description: "Feature branch creation, numbering (sequential/timestamp), templating, validation, and Git remote detection"
author: spec-kit-core
repository: https://github.com/github/spec-kit
Expand Down
2 changes: 1 addition & 1 deletion extensions/git/scripts/bash/create-new-feature-branch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
fi

# Trim whitespace and validate description is not empty
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
FEATURE_DESCRIPTION=$(printf '%s\n' "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
exit 1
Expand Down
2 changes: 1 addition & 1 deletion scripts/bash/create-new-feature.sh
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
fi

# Trim whitespace and validate description is not empty (e.g., user passed only whitespace)
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
FEATURE_DESCRIPTION=$(printf '%s\n' "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
exit 1
Expand Down
2 changes: 1 addition & 1 deletion tests/extensions/git/test_git_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ def test_manifest_validates(self):

m = ExtensionManifest(EXT_DIR / "extension.yml")
assert m.id == "git"
assert m.version == "1.0.1"
assert m.version == "1.0.2"

def test_manifest_commands(self):
"""Manifest declares expected commands."""
Expand Down
80 changes: 80 additions & 0 deletions tests/extensions/git/test_git_extension_python_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ def _twin_projects(tmp_path: Path, *, git: bool = True) -> tuple[Path, Path]:
return bash_proj, py_proj


def _branch_state(project: Path) -> tuple[str, str]:
return tuple(
subprocess.run(
["git", *args], cwd=project, check=True, capture_output=True, text=True,
).stdout.strip()
for args in (("symbolic-ref", "--short", "HEAD"), ("for-each-ref",))
)


def _assert_parity(
bash_result: subprocess.CompletedProcess,
py_result: subprocess.CompletedProcess,
Expand Down Expand Up @@ -120,6 +129,77 @@ def _without_persist_hint(stderr: str) -> str:

@requires_bash
class TestCreateFeatureBranchParity:
@pytest.mark.parametrize("dry_run", [False, True], ids=["create", "dry_run"])
@pytest.mark.parametrize(
"description,short_name,expected",
[
("-n", None, "001-n"),
("-e", None, "001-e"),
("-E", None, "001-e"),
("-neE", None, "001-nee"),
("Add user authentication", None, "001-user-authentication"),
(" \tAdd user authentication\t ", None, "001-user-authentication"),
("Add user authentication", "custom-name", "001-custom-name"),
("-n", "custom-name", "001-custom-name"),
],
ids=["n", "e", "E", "neE", "ordinary", "padded", "short_name", "literal_short_name"],
)
def test_literal_description(
self, tmp_path: Path, dry_run: bool, description: str,
short_name: str | None, expected: str,
):
"""Option-like descriptions survive trimming without --short-name (#5096)."""
projects = _twin_projects(tmp_path)
before = [_branch_state(project) for project in projects]
args = ["--json"]
if dry_run:
args.append("--dry-run")
if short_name is not None:
args.extend(["--short-name", short_name])
args.append(description)
b = _run_bash("create-new-feature-branch.sh", projects[0], *args)
p = _run_py("create-new-feature-branch", projects[1], *args)
_assert_parity(b, p)

for result, project, initial_state in zip((b, p), projects, before):
assert result.returncode == 0, result.stderr
expected_data = {"BRANCH_NAME": expected, "FEATURE_NUM": "001"}
if dry_run:
expected_data["DRY_RUN"] = True
assert _branch_state(project) == initial_state
else:
assert _branch_state(project)[0] == expected
assert json.loads(result.stdout) == expected_data
assert not (project / "specs").exists()
assert not (project / ".specify" / "feature.json").exists()

@pytest.mark.parametrize("dry_run", [False, True], ids=["create", "dry_run"])
@pytest.mark.parametrize("description", [" ", " \t\t "], ids=["spaces", "tabs"])
@pytest.mark.parametrize("short_name", [None, "custom-name"], ids=["default", "short_name"])
def test_whitespace_description(
self, tmp_path: Path, dry_run: bool, description: str, short_name: str | None,
):
projects = _twin_projects(tmp_path)
before = [_branch_state(project) for project in projects]
args = ["--json"]
if dry_run:
args.append("--dry-run")
if short_name is not None:
args.extend(["--short-name", short_name])
args.append(description)
b = _run_bash("create-new-feature-branch.sh", projects[0], *args)
p = _run_py("create-new-feature-branch", projects[1], *args)

assert b.returncode == p.returncode == 1
assert b.stdout == p.stdout == ""
assert b.stderr == p.stderr == (
"Error: Feature description cannot be empty or contain only whitespace\n"
)
for project, initial_state in zip(projects, before):
assert _branch_state(project) == initial_state
assert not (project / "specs").exists()
assert not (project / ".specify" / "feature.json").exists()

def test_sequential_branch_json(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "Add user authentication")
Expand Down
87 changes: 87 additions & 0 deletions tests/test_create_new_feature_python_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -1612,6 +1612,93 @@ def test_python_dash_prefixed_short_name_matches_bash(
assert json_stdout(bash)["BRANCH_NAME"] == expected


@requires_bash
@pytest.mark.parametrize("dry_run", [False, True], ids=["create", "dry_run"])
@pytest.mark.parametrize(
"description,short_name,expected",
[
("-n", None, "001-n"),
("-e", None, "001-e"),
("-E", None, "001-e"),
("-neE", None, "001-nee"),
("Add user authentication", None, "001-user-authentication"),
(" \tAdd user authentication\t ", None, "001-user-authentication"),
("-n", "custom-name", "001-custom-name"),
],
ids=["n", "e", "E", "neE", "ordinary", "padded", "explicit_short_name"],
)
def test_python_literal_description_matches_bash(
repo_pair: tuple[Path, Path],
dry_run: bool,
description: str,
short_name: str | None,
expected: str,
) -> None:
"""Descriptions resembling echo options must survive trimming (#5096)."""
repo_a, repo_b = repo_pair
args = ["--json"]
if dry_run:
args.append("--dry-run")
if short_name is not None:
args.extend(["--short-name", short_name])
args.append(description)
py = run(py_cmd(repo_b, SCRIPT, *args), repo_b)
bash = run(bash_cmd(repo_a, SCRIPT, *args), repo_a)

for result, current in ((py, repo_b), (bash, repo_a)):
assert result.returncode == 0, result.stderr
output = json_stdout(result)
spec = current / "specs" / expected / "spec.md"
expected_output = {
"BRANCH_NAME": expected,
"SPEC_FILE": str(spec),
"FEATURE_NUM": "001",
}
state = current / ".specify" / "feature.json"
if dry_run:
expected_output["DRY_RUN"] = True
assert not (current / "specs").exists()
assert not state.exists()
else:
assert spec.read_bytes() == (
current / ".specify" / "templates" / "spec-template.md"
).read_bytes()
assert state.read_bytes() == (
f'{{"feature_directory":"specs/{expected}"}}\n'.encode("utf-8")
)
assert output == expected_output
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
)


@requires_bash
@pytest.mark.parametrize("dry_run", [False, True], ids=["create", "dry_run"])
@pytest.mark.parametrize("description", [" ", " \t\t "], ids=["spaces", "tabs"])
def test_python_whitespace_description_matches_bash(
repo_pair: tuple[Path, Path], dry_run: bool, description: str
) -> None:
repo_a, repo_b = repo_pair
args = ["--json"]
if dry_run:
args.append("--dry-run")
args.append(description)
bash = run(bash_cmd(repo_a, SCRIPT, *args), repo_a)
py = run(py_cmd(repo_b, SCRIPT, *args), repo_b)

assert py.returncode == bash.returncode == 1
assert py.stdout == bash.stdout == ""
assert py.stderr == bash.stderr == (
"Error: Feature description cannot be empty or contain only whitespace\n"
)
for current in repo_pair:
assert not (current / "specs").exists()
assert not (current / ".specify" / "feature.json").exists()


@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
@pytest.mark.parametrize(
("description", "expected"),
Expand Down
Loading