diff --git a/extensions/catalog.json b/extensions/catalog.json index 1c423d4707..7c6c542b50 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -51,7 +51,7 @@ "git": { "name": "Git Branching Workflow", "id": "git", - "version": "1.0.2", + "version": "1.0.3", "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 2d762a126a..cf3f728b77 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.2" + version: "1.0.3" 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/python/create_new_feature_branch.py b/extensions/git/scripts/python/create_new_feature_branch.py index 0cfb6a5c09..7073f3ef7d 100644 --- a/extensions/git/scripts/python/create_new_feature_branch.py +++ b/extensions/git/scripts/python/create_new_feature_branch.py @@ -24,6 +24,7 @@ SCRIPT_DIR = Path(__file__).resolve().parent MAX_BRANCH_LENGTH = 244 # GitHub enforces a 244-byte limit on branch names +MAX_FEATURE_NUMBER = 2**63 - 1 USAGE = ( "Usage: create_new_feature_branch.py [--json] [--dry-run] " @@ -76,6 +77,16 @@ def _persist_hint(var_name: str, value: str) -> str: return f"export {var_name}={escaped_value}" +def _int64_from_digits(value: str) -> int | None: + normalized = value.lstrip("0") or "0" + maximum = str(MAX_FEATURE_NUMBER) + if len(normalized) > len(maximum) or ( + len(normalized) == len(maximum) and normalized > maximum + ): + return None + return int(normalized, 10) + + @dataclass class Args: json_mode: bool = False @@ -113,6 +124,14 @@ def parse_args(argv: list[str]) -> Args: if not re.fullmatch(r"[0-9]+", args.branch_number): _err("Error: --number must be a non-negative integer") raise SystemExit(1) + normalized_number = _int64_from_digits(args.branch_number) + if normalized_number is None: + _err( + "Error: --number must be between 0 and " + f"{MAX_FEATURE_NUMBER}, got '{args.branch_number}'" + ) + raise SystemExit(1) + args.branch_number = str(normalized_number) elif arg == "--timestamp": args.use_timestamp = True elif arg in ("--help", "-h"): @@ -530,7 +549,21 @@ def build_branch_name(feature_num: str, branch_suffix: str) -> str: else: branch_number = get_highest_from_specs(specs_dir) + 1 - feature_num = f"{int(branch_number):03d}" + try: + feature_number = int(branch_number) + except ValueError: + _err( + "Error: feature number must be between 0 and " + f"{MAX_FEATURE_NUMBER}, got '{branch_number}'" + ) + return 1 + if not 0 <= feature_number <= MAX_FEATURE_NUMBER: + _err( + "Error: feature number must be between 0 and " + f"{MAX_FEATURE_NUMBER}, got '{branch_number}'" + ) + return 1 + feature_num = f"{feature_number:03d}" branch_name = build_branch_name(feature_num, branch_suffix) branch_byte_len = _byte_length(branch_name) diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 538925a8c7..8e1c2652a4 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -179,7 +179,7 @@ def test_manifest_validates(self): m = ExtensionManifest(EXT_DIR / "extension.yml") assert m.id == "git" - assert m.version == "1.0.2" + assert m.version == "1.0.3" 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 64d9bdbe19..b8cfd57b23 100644 --- a/tests/extensions/git/test_git_extension_python_parity.py +++ b/tests/extensions/git/test_git_extension_python_parity.py @@ -322,6 +322,92 @@ def test_explicit_number(self, tmp_path: Path): _assert_parity(b, p) assert json.loads(p.stdout)["FEATURE_NUM"] == "042" + def test_explicit_number_max_int64_accepted(self, tmp_path: Path): + _, py_proj = _twin_projects(tmp_path) + p = _run_py( + "create-new-feature-branch", + py_proj, + "--json", + "--dry-run", + "--number", + str(2**63 - 1), + "some feature", + ) + assert p.returncode == 0, p.stderr + assert json.loads(p.stdout)["FEATURE_NUM"] == str(2**63 - 1) + + def test_explicit_number_zero_padded_input_accepted(self, tmp_path: Path): + _, py_proj = _twin_projects(tmp_path) + p = _run_py( + "create-new-feature-branch", + py_proj, + "--json", + "--dry-run", + "--number", + "0" * 5000 + "1", + "some feature", + ) + assert p.returncode == 0, p.stderr + assert json.loads(p.stdout)["FEATURE_NUM"] == "001" + + def test_explicit_number_above_max_int64_rejected(self, tmp_path: Path): + """Pin Python-side bound checking; bash currently has no equivalent guard.""" + _, py_proj = _twin_projects(tmp_path) + too_large = str(2**63) + p = _run_py( + "create-new-feature-branch", + py_proj, + "--json", + "--dry-run", + "--number", + too_large, + "some feature", + ) + assert p.returncode == 1 + assert ( + "Error: --number must be between 0 and 9223372036854775807, " + f"got '{too_large}'" + ) in p.stderr + assert "Traceback" not in p.stderr + + def test_explicit_number_oversized_digits_rejected_without_traceback( + self, tmp_path: Path + ): + _, py_proj = _twin_projects(tmp_path) + oversized = "9" * 5000 + p = _run_py( + "create-new-feature-branch", + py_proj, + "--json", + "--dry-run", + "--number", + oversized, + "some feature", + ) + assert p.returncode == 1 + assert ( + "Error: --number must be between 0 and 9223372036854775807, " + f"got '{oversized}'" + ) in p.stderr + assert "Traceback" not in p.stderr + + def test_auto_detected_number_above_max_int64_rejected(self, tmp_path: Path): + _, py_proj = _twin_projects(tmp_path) + (py_proj / "specs" / f"{2**63 - 1}-existing").mkdir(parents=True) + p = _run_py( + "create-new-feature-branch", + py_proj, + "--json", + "--dry-run", + "some feature", + ) + assert p.returncode == 1 + assert ( + "Error: feature number must be between 0 and 9223372036854775807, " + "got '9223372036854775808'" + ) in p.stderr + assert "Traceback" not in p.stderr + def test_timestamp_mode_format(self, tmp_path: Path): _, py_proj = _twin_projects(tmp_path) p = _run_py(