diff --git a/extensions/catalog.json b/extensions/catalog.json index c20cec03e6..cfd475d973 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -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", diff --git a/extensions/git/extension.yml b/extensions/git/extension.yml index 90a7e56769..2d762a126a 100644 --- a/extensions/git/extension.yml +++ b/extensions/git/extension.yml @@ -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 diff --git a/extensions/git/scripts/bash/create-new-feature-branch.sh b/extensions/git/scripts/bash/create-new-feature-branch.sh index 5303bb97c9..c08718e427 100755 --- a/extensions/git/scripts/bash/create-new-feature-branch.sh +++ b/extensions/git/scripts/bash/create-new-feature-branch.sh @@ -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 diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 3ce2f9011c..c3b7be844e 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -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 diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 1760e496d1..4915544dbc 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -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.""" diff --git a/tests/extensions/git/test_git_extension_python_parity.py b/tests/extensions/git/test_git_extension_python_parity.py index 8cae45e783..6d5b6d6b73 100644 --- a/tests/extensions/git/test_git_extension_python_parity.py +++ b/tests/extensions/git/test_git_extension_python_parity.py @@ -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, @@ -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") diff --git a/tests/test_create_new_feature_python_parity.py b/tests/test_create_new_feature_python_parity.py index 177bdea30d..3fffea483b 100644 --- a/tests/test_create_new_feature_python_parity.py +++ b/tests/test_create_new_feature_python_parity.py @@ -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"),