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.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",
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.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
Expand Down
35 changes: 34 additions & 1 deletion extensions/git/scripts/python/create_new_feature_branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 7bda569: the Git extension manifest and catalog are both bumped to 1.0.2.


USAGE = (
"Usage: create_new_feature_branch.py [--json] [--dry-run] "
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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)
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 @@ -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."""
Expand Down
86 changes: 86 additions & 0 deletions tests/extensions/git/test_git_extension_python_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading